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.
- 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 +7 -2
- 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 +71 -12
- package/dist/cli.js +202 -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 +2 -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 +29 -0
- package/package.json +3 -2
- package/schemas/platform-profile.schema.json +5 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { NovelCliError } from "./errors.js";
|
|
3
|
+
import { pathExists, readJsonFile, readTextFile } from "./fs-utils.js";
|
|
4
|
+
import { tryResolveVolumeChapterRange } from "./consistency-auditor.js";
|
|
5
|
+
import { formatStepId, pad2, pad3 } from "./steps.js";
|
|
6
|
+
import { isPlainObject } from "./type-guards.js";
|
|
7
|
+
export const VOL_REVIEW_RELS = {
|
|
8
|
+
dir: "staging/vol-review",
|
|
9
|
+
qualitySummary: "staging/vol-review/quality-summary.json",
|
|
10
|
+
auditReport: "staging/vol-review/audit-report.json",
|
|
11
|
+
reviewReport: "staging/vol-review/review-report.md",
|
|
12
|
+
foreshadowStatus: "staging/vol-review/foreshadow-status.json"
|
|
13
|
+
};
|
|
14
|
+
function safeFiniteNumber(v) {
|
|
15
|
+
if (typeof v !== "number")
|
|
16
|
+
return null;
|
|
17
|
+
if (!Number.isFinite(v))
|
|
18
|
+
return null;
|
|
19
|
+
return v;
|
|
20
|
+
}
|
|
21
|
+
function safeInt(v) {
|
|
22
|
+
if (typeof v !== "number")
|
|
23
|
+
return null;
|
|
24
|
+
if (!Number.isInteger(v))
|
|
25
|
+
return null;
|
|
26
|
+
return v;
|
|
27
|
+
}
|
|
28
|
+
function safeBool(v) {
|
|
29
|
+
if (typeof v !== "boolean")
|
|
30
|
+
return null;
|
|
31
|
+
return v;
|
|
32
|
+
}
|
|
33
|
+
function safeString(v) {
|
|
34
|
+
if (typeof v !== "string")
|
|
35
|
+
return null;
|
|
36
|
+
const trimmed = v.trim();
|
|
37
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
38
|
+
}
|
|
39
|
+
function normalizeForeshadowFile(raw) {
|
|
40
|
+
let obj = raw;
|
|
41
|
+
if (Array.isArray(obj))
|
|
42
|
+
obj = { foreshadowing: obj };
|
|
43
|
+
if (!isPlainObject(obj))
|
|
44
|
+
return null;
|
|
45
|
+
const list = obj.foreshadowing;
|
|
46
|
+
if (!Array.isArray(list))
|
|
47
|
+
return null;
|
|
48
|
+
const items = [];
|
|
49
|
+
for (const it of list) {
|
|
50
|
+
if (!isPlainObject(it))
|
|
51
|
+
continue;
|
|
52
|
+
const id = safeString(it.id);
|
|
53
|
+
if (!id)
|
|
54
|
+
continue;
|
|
55
|
+
items.push({ ...it, id });
|
|
56
|
+
}
|
|
57
|
+
return { foreshadowing: items };
|
|
58
|
+
}
|
|
59
|
+
export async function collectVolumeData(args) {
|
|
60
|
+
const volume = args.checkpoint.current_volume;
|
|
61
|
+
const endChapter = args.checkpoint.last_completed_chapter;
|
|
62
|
+
if (!Number.isInteger(volume) || volume < 1)
|
|
63
|
+
throw new NovelCliError(`Invalid checkpoint.current_volume: ${String(volume)}`, 2);
|
|
64
|
+
if (!Number.isInteger(endChapter) || endChapter < 0)
|
|
65
|
+
throw new NovelCliError(`Invalid checkpoint.last_completed_chapter: ${String(endChapter)}`, 2);
|
|
66
|
+
const warnings = [];
|
|
67
|
+
const resolvedRange = (await tryResolveVolumeChapterRange({ rootDir: args.rootDir, volume })) ??
|
|
68
|
+
(endChapter >= 1 ? { start: Math.max(1, endChapter - 9), end: endChapter } : null);
|
|
69
|
+
if (!resolvedRange) {
|
|
70
|
+
return {
|
|
71
|
+
schema_version: 1,
|
|
72
|
+
generated_at: new Date().toISOString(),
|
|
73
|
+
as_of: { volume, chapter: endChapter },
|
|
74
|
+
chapter_range: [0, 0],
|
|
75
|
+
stats: { chapters_total: 0, chapters_with_eval: 0, overall_avg: null, overall_min: null, overall_max: null },
|
|
76
|
+
chapters: [],
|
|
77
|
+
low_chapters: [],
|
|
78
|
+
warnings: endChapter === 0 ? ["No committed chapters yet; volume review summary is empty."] : ["Unable to resolve chapter range for volume review."]
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const chapterRange = [resolvedRange.start, resolvedRange.end];
|
|
82
|
+
const chapters = [];
|
|
83
|
+
const scores = [];
|
|
84
|
+
const lowChapters = [];
|
|
85
|
+
for (let chapter = resolvedRange.start; chapter <= resolvedRange.end; chapter++) {
|
|
86
|
+
const evalRel = `evaluations/chapter-${pad3(chapter)}-eval.json`;
|
|
87
|
+
const evalAbs = join(args.rootDir, evalRel);
|
|
88
|
+
const exists = await pathExists(evalAbs);
|
|
89
|
+
if (!exists) {
|
|
90
|
+
warnings.push(`Missing eval file: ${evalRel}`);
|
|
91
|
+
chapters.push({
|
|
92
|
+
chapter,
|
|
93
|
+
eval_path: evalRel,
|
|
94
|
+
overall_final: null,
|
|
95
|
+
gate_decision: null,
|
|
96
|
+
revisions: null,
|
|
97
|
+
force_passed: null,
|
|
98
|
+
has_high_confidence_violation: null
|
|
99
|
+
});
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
let raw;
|
|
103
|
+
try {
|
|
104
|
+
raw = await readJsonFile(evalAbs);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
108
|
+
warnings.push(`Failed to read ${evalRel}: ${message}`);
|
|
109
|
+
chapters.push({
|
|
110
|
+
chapter,
|
|
111
|
+
eval_path: evalRel,
|
|
112
|
+
overall_final: null,
|
|
113
|
+
gate_decision: null,
|
|
114
|
+
revisions: null,
|
|
115
|
+
force_passed: null,
|
|
116
|
+
has_high_confidence_violation: null
|
|
117
|
+
});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!isPlainObject(raw)) {
|
|
121
|
+
warnings.push(`Invalid eval JSON (expected object): ${evalRel}`);
|
|
122
|
+
chapters.push({
|
|
123
|
+
chapter,
|
|
124
|
+
eval_path: evalRel,
|
|
125
|
+
overall_final: null,
|
|
126
|
+
gate_decision: null,
|
|
127
|
+
revisions: null,
|
|
128
|
+
force_passed: null,
|
|
129
|
+
has_high_confidence_violation: null
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const obj = raw;
|
|
134
|
+
const overall = safeFiniteNumber(obj.overall_final) ??
|
|
135
|
+
safeFiniteNumber(obj.overall) ??
|
|
136
|
+
(isPlainObject(obj.judges) ? safeFiniteNumber(obj.judges.overall_final) : null);
|
|
137
|
+
const gate = isPlainObject(obj.metadata) && isPlainObject(obj.metadata.gate)
|
|
138
|
+
? obj.metadata.gate
|
|
139
|
+
: isPlainObject(obj.gate)
|
|
140
|
+
? obj.gate
|
|
141
|
+
: null;
|
|
142
|
+
const gate_decision = gate ? safeString(gate.decision) : null;
|
|
143
|
+
const revisions = gate ? safeInt(gate.revisions) : null;
|
|
144
|
+
const force_passed = gate ? safeBool(gate.force_passed) : null;
|
|
145
|
+
const has_high_confidence_violation = gate ? safeBool(gate.has_high_confidence_violation) : null;
|
|
146
|
+
if (overall !== null) {
|
|
147
|
+
scores.push(overall);
|
|
148
|
+
if (overall < 3.5)
|
|
149
|
+
lowChapters.push({ chapter, overall_final: overall });
|
|
150
|
+
}
|
|
151
|
+
chapters.push({
|
|
152
|
+
chapter,
|
|
153
|
+
eval_path: evalRel,
|
|
154
|
+
overall_final: overall,
|
|
155
|
+
gate_decision,
|
|
156
|
+
revisions,
|
|
157
|
+
force_passed,
|
|
158
|
+
has_high_confidence_violation
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null;
|
|
162
|
+
const min = scores.length > 0 ? Math.min(...scores) : null;
|
|
163
|
+
const max = scores.length > 0 ? Math.max(...scores) : null;
|
|
164
|
+
lowChapters.sort((a, b) => a.overall_final - b.overall_final || a.chapter - b.chapter);
|
|
165
|
+
return {
|
|
166
|
+
schema_version: 1,
|
|
167
|
+
generated_at: new Date().toISOString(),
|
|
168
|
+
as_of: { volume, chapter: endChapter },
|
|
169
|
+
chapter_range: chapterRange,
|
|
170
|
+
stats: {
|
|
171
|
+
chapters_total: resolvedRange.end - resolvedRange.start + 1,
|
|
172
|
+
chapters_with_eval: scores.length,
|
|
173
|
+
overall_avg: avg === null ? null : Number(avg.toFixed(4)),
|
|
174
|
+
overall_min: min,
|
|
175
|
+
overall_max: max
|
|
176
|
+
},
|
|
177
|
+
chapters,
|
|
178
|
+
low_chapters: lowChapters,
|
|
179
|
+
warnings
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
export async function computeForeshadowingAudit(args) {
|
|
183
|
+
const volume = args.checkpoint.current_volume;
|
|
184
|
+
const asOfChapter = args.checkpoint.last_completed_chapter;
|
|
185
|
+
const warnings = [];
|
|
186
|
+
const globalRel = "foreshadowing/global.json";
|
|
187
|
+
const volumeRel = `volumes/vol-${pad2(volume)}/foreshadowing.json`;
|
|
188
|
+
const globalAbs = join(args.rootDir, globalRel);
|
|
189
|
+
const volumeAbs = join(args.rootDir, volumeRel);
|
|
190
|
+
const globalRaw = (await pathExists(globalAbs)) ? await readJsonFile(globalAbs).catch((err) => {
|
|
191
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
192
|
+
warnings.push(`Failed to read ${globalRel}: ${message}`);
|
|
193
|
+
return null;
|
|
194
|
+
}) : null;
|
|
195
|
+
const volumeRaw = (await pathExists(volumeAbs)) ? await readJsonFile(volumeAbs).catch((err) => {
|
|
196
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
197
|
+
warnings.push(`Failed to read ${volumeRel}: ${message}`);
|
|
198
|
+
return null;
|
|
199
|
+
}) : null;
|
|
200
|
+
const global = globalRaw === null ? null : normalizeForeshadowFile(globalRaw);
|
|
201
|
+
const plan = volumeRaw === null ? null : normalizeForeshadowFile(volumeRaw);
|
|
202
|
+
if (globalRaw !== null && !global)
|
|
203
|
+
warnings.push(`Invalid ${globalRel}: expected a list or {foreshadowing:[...]}.`);
|
|
204
|
+
if (volumeRaw !== null && !plan)
|
|
205
|
+
warnings.push(`Invalid ${volumeRel}: expected a list or {foreshadowing:[...]}.`);
|
|
206
|
+
const globalItems = global?.foreshadowing ?? [];
|
|
207
|
+
const planItems = plan?.foreshadowing ?? [];
|
|
208
|
+
const globalIndex = new Map(globalItems.map((it) => [it.id, it]));
|
|
209
|
+
const activeCount = globalItems.filter((it) => String(it.status ?? "") !== "resolved").length;
|
|
210
|
+
const resolvedCount = globalItems.filter((it) => String(it.status ?? "") === "resolved").length;
|
|
211
|
+
const overdueShort = [];
|
|
212
|
+
for (const it of globalItems) {
|
|
213
|
+
const scope = safeString(it.scope);
|
|
214
|
+
const status = safeString(it.status);
|
|
215
|
+
if (scope !== "short")
|
|
216
|
+
continue;
|
|
217
|
+
if (status === "resolved")
|
|
218
|
+
continue;
|
|
219
|
+
const trRaw = it.target_resolve_range;
|
|
220
|
+
if (!Array.isArray(trRaw) || trRaw.length !== 2)
|
|
221
|
+
continue;
|
|
222
|
+
const start = safeInt(trRaw[0]);
|
|
223
|
+
const end = safeInt(trRaw[1]);
|
|
224
|
+
if (start === null || end === null)
|
|
225
|
+
continue;
|
|
226
|
+
if (asOfChapter > end) {
|
|
227
|
+
overdueShort.push({ id: it.id, target_resolve_range: [start, end], as_of_chapter: asOfChapter });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const planMissingInGlobal = [];
|
|
231
|
+
const planResolvedInGlobal = [];
|
|
232
|
+
for (const it of planItems) {
|
|
233
|
+
const existing = globalIndex.get(it.id);
|
|
234
|
+
if (!existing)
|
|
235
|
+
planMissingInGlobal.push(it.id);
|
|
236
|
+
else if (safeString(existing.status) === "resolved")
|
|
237
|
+
planResolvedInGlobal.push(it.id);
|
|
238
|
+
}
|
|
239
|
+
planMissingInGlobal.sort();
|
|
240
|
+
planResolvedInGlobal.sort();
|
|
241
|
+
return {
|
|
242
|
+
schema_version: 1,
|
|
243
|
+
generated_at: new Date().toISOString(),
|
|
244
|
+
as_of: { volume, chapter: asOfChapter },
|
|
245
|
+
global: { total: globalItems.length, active_count: activeCount, resolved_count: resolvedCount },
|
|
246
|
+
overdue_short: overdueShort,
|
|
247
|
+
plan: plan ? { planned_total: planItems.length, missing_in_global: planMissingInGlobal, resolved_in_global: planResolvedInGlobal } : null,
|
|
248
|
+
warnings
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
export async function computeBridgeCheck(args) {
|
|
252
|
+
const warnings = [];
|
|
253
|
+
const storylinesRel = "storylines/storylines.json";
|
|
254
|
+
const abs = join(args.rootDir, storylinesRel);
|
|
255
|
+
if (!(await pathExists(abs))) {
|
|
256
|
+
return {
|
|
257
|
+
schema_version: 1,
|
|
258
|
+
generated_at: new Date().toISOString(),
|
|
259
|
+
volume: args.volume,
|
|
260
|
+
broken: [],
|
|
261
|
+
warnings: [`Missing optional file: ${storylinesRel}`]
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
let raw;
|
|
265
|
+
try {
|
|
266
|
+
raw = await readJsonFile(abs);
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
270
|
+
return {
|
|
271
|
+
schema_version: 1,
|
|
272
|
+
generated_at: new Date().toISOString(),
|
|
273
|
+
volume: args.volume,
|
|
274
|
+
broken: [],
|
|
275
|
+
warnings: [`Failed to read ${storylinesRel}: ${message}`]
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (!isPlainObject(raw)) {
|
|
279
|
+
return {
|
|
280
|
+
schema_version: 1,
|
|
281
|
+
generated_at: new Date().toISOString(),
|
|
282
|
+
volume: args.volume,
|
|
283
|
+
broken: [],
|
|
284
|
+
warnings: [`Invalid ${storylinesRel}: expected JSON object.`]
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const obj = raw;
|
|
288
|
+
const relsRaw = obj.relationships;
|
|
289
|
+
if (!Array.isArray(relsRaw)) {
|
|
290
|
+
return {
|
|
291
|
+
schema_version: 1,
|
|
292
|
+
generated_at: new Date().toISOString(),
|
|
293
|
+
volume: args.volume,
|
|
294
|
+
broken: [],
|
|
295
|
+
warnings
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const idExists = (id) => args.foreshadowIds.global.has(id) || args.foreshadowIds.plan.has(id);
|
|
299
|
+
const broken = [];
|
|
300
|
+
for (const rel of relsRaw) {
|
|
301
|
+
if (!isPlainObject(rel))
|
|
302
|
+
continue;
|
|
303
|
+
const from = safeString(rel.from);
|
|
304
|
+
const to = safeString(rel.to);
|
|
305
|
+
const type = safeString(rel.type);
|
|
306
|
+
const bridges = rel.bridges;
|
|
307
|
+
if (!isPlainObject(bridges))
|
|
308
|
+
continue;
|
|
309
|
+
const shared = bridges.shared_foreshadowing;
|
|
310
|
+
if (!Array.isArray(shared))
|
|
311
|
+
continue;
|
|
312
|
+
for (const idRaw of shared) {
|
|
313
|
+
const id = safeString(idRaw);
|
|
314
|
+
if (!id)
|
|
315
|
+
continue;
|
|
316
|
+
if (idExists(id))
|
|
317
|
+
continue;
|
|
318
|
+
broken.push({
|
|
319
|
+
missing_id: id,
|
|
320
|
+
relationship: { from, to, type }
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
broken.sort((a, b) => String(a.missing_id ?? "").localeCompare(String(b.missing_id ?? "")));
|
|
325
|
+
return {
|
|
326
|
+
schema_version: 1,
|
|
327
|
+
generated_at: new Date().toISOString(),
|
|
328
|
+
volume: args.volume,
|
|
329
|
+
broken,
|
|
330
|
+
warnings
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
export async function computeStorylineRhythm(args) {
|
|
334
|
+
const warnings = [];
|
|
335
|
+
const scheduleRel = `volumes/vol-${pad2(args.volume)}/storyline-schedule.json`;
|
|
336
|
+
const scheduleAbs = join(args.rootDir, scheduleRel);
|
|
337
|
+
if (!(await pathExists(scheduleAbs))) {
|
|
338
|
+
warnings.push(`Missing optional file: ${scheduleRel}`);
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
// Best-effort parse schedule: we only use it as a presence signal for now.
|
|
342
|
+
try {
|
|
343
|
+
await readJsonFile(scheduleAbs);
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
347
|
+
warnings.push(`Failed to read ${scheduleRel}: ${message}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const appearances = new Map();
|
|
351
|
+
const lastSeen = new Map();
|
|
352
|
+
const [start, end] = args.chapter_range;
|
|
353
|
+
const re = /storyline_id:\s*([a-zA-Z0-9_-]+)/gu;
|
|
354
|
+
for (let chapter = start; chapter <= end; chapter++) {
|
|
355
|
+
const rel = `summaries/chapter-${pad3(chapter)}-summary.md`;
|
|
356
|
+
const abs = join(args.rootDir, rel);
|
|
357
|
+
if (!(await pathExists(abs)))
|
|
358
|
+
continue;
|
|
359
|
+
let text;
|
|
360
|
+
try {
|
|
361
|
+
text = await readTextFile(abs);
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const idsThisChapter = new Set();
|
|
367
|
+
for (const m of text.matchAll(re)) {
|
|
368
|
+
const id = m[1] ?? "";
|
|
369
|
+
if (!id)
|
|
370
|
+
continue;
|
|
371
|
+
idsThisChapter.add(id);
|
|
372
|
+
}
|
|
373
|
+
if (idsThisChapter.size === 0)
|
|
374
|
+
continue;
|
|
375
|
+
for (const id of idsThisChapter) {
|
|
376
|
+
appearances.set(id, (appearances.get(id) ?? 0) + 1);
|
|
377
|
+
lastSeen.set(id, chapter);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const appearancesObj = {};
|
|
381
|
+
const lastSeenObj = {};
|
|
382
|
+
for (const [k, v] of appearances.entries())
|
|
383
|
+
appearancesObj[k] = v;
|
|
384
|
+
for (const [k, v] of lastSeen.entries())
|
|
385
|
+
lastSeenObj[k] = v;
|
|
386
|
+
return {
|
|
387
|
+
schema_version: 1,
|
|
388
|
+
generated_at: new Date().toISOString(),
|
|
389
|
+
volume: args.volume,
|
|
390
|
+
chapter_range: args.chapter_range,
|
|
391
|
+
appearances: appearancesObj,
|
|
392
|
+
last_seen: lastSeenObj,
|
|
393
|
+
warnings
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
export async function computeReviewNextStep(projectRootDir, checkpoint) {
|
|
397
|
+
const qualitySummaryAbs = join(projectRootDir, VOL_REVIEW_RELS.qualitySummary);
|
|
398
|
+
const auditReportAbs = join(projectRootDir, VOL_REVIEW_RELS.auditReport);
|
|
399
|
+
const reviewReportAbs = join(projectRootDir, VOL_REVIEW_RELS.reviewReport);
|
|
400
|
+
const foreshadowAbs = join(projectRootDir, VOL_REVIEW_RELS.foreshadowStatus);
|
|
401
|
+
const hasQualitySummary = await pathExists(qualitySummaryAbs);
|
|
402
|
+
const hasAuditReport = await pathExists(auditReportAbs);
|
|
403
|
+
const hasReviewReport = await pathExists(reviewReportAbs);
|
|
404
|
+
const hasForeshadowStatus = await pathExists(foreshadowAbs);
|
|
405
|
+
const evidence = { hasQualitySummary, hasAuditReport, hasReviewReport, hasForeshadowStatus };
|
|
406
|
+
if (!hasQualitySummary) {
|
|
407
|
+
return {
|
|
408
|
+
step: formatStepId({ kind: "review", phase: "collect" }),
|
|
409
|
+
reason: "vol_review:missing_quality_summary",
|
|
410
|
+
inflight: { chapter: null, pipeline_stage: null },
|
|
411
|
+
evidence
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
if (!hasAuditReport) {
|
|
415
|
+
return {
|
|
416
|
+
step: formatStepId({ kind: "review", phase: "audit" }),
|
|
417
|
+
reason: "vol_review:missing_audit_report",
|
|
418
|
+
inflight: { chapter: null, pipeline_stage: null },
|
|
419
|
+
evidence
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
if (!hasReviewReport) {
|
|
423
|
+
return {
|
|
424
|
+
step: formatStepId({ kind: "review", phase: "report" }),
|
|
425
|
+
reason: "vol_review:missing_review_report",
|
|
426
|
+
inflight: { chapter: null, pipeline_stage: null },
|
|
427
|
+
evidence
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
if (!hasForeshadowStatus) {
|
|
431
|
+
return {
|
|
432
|
+
step: formatStepId({ kind: "review", phase: "cleanup" }),
|
|
433
|
+
reason: "vol_review:missing_foreshadow_status",
|
|
434
|
+
inflight: { chapter: null, pipeline_stage: null },
|
|
435
|
+
evidence
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
step: formatStepId({ kind: "review", phase: "transition" }),
|
|
440
|
+
reason: "vol_review:ready_transition",
|
|
441
|
+
inflight: { chapter: null, pipeline_stage: null },
|
|
442
|
+
evidence
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
// Alias for tasks wording.
|
|
446
|
+
export async function computeReviewNext(projectRootDir, checkpoint) {
|
|
447
|
+
return await computeReviewNextStep(projectRootDir, checkpoint);
|
|
448
|
+
}
|
package/docs/user/novel-cli.md
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
| `novel validate <step>` | 校验 step 产物是否齐全/合规 |
|
|
24
24
|
| `novel advance <step>` | 校验通过后推进 checkpoint |
|
|
25
25
|
| `novel commit --chapter N` | 提交 staging 事务到正式目录(写入) |
|
|
26
|
+
| `novel commit --volume N` | 提交卷规划 staging 产物到 `volumes/`(写入) |
|
|
26
27
|
| `novel lock status/clear` | 查看/清理写入锁(解决中断导致的 stale lock) |
|
|
27
28
|
| `novel promises init/report` | 承诺台账:初始化与窗口报告 |
|
|
28
29
|
| `novel engagement report` | 参与度密度:窗口报告 |
|
|
@@ -190,6 +191,34 @@ novel lock clear
|
|
|
190
191
|
|
|
191
192
|
> 常见场景:执行器在写入阶段中断(断电/kill/异常退出),留下 stale lock;此时可先 `novel lock status` 确认,再执行 `novel lock clear`。
|
|
192
193
|
|
|
194
|
+
## 卷规划(VOL_PLANNING)
|
|
195
|
+
|
|
196
|
+
当 `.checkpoint.json.orchestrator_state == "VOL_PLANNING"` 时,`novel next` 会进入卷规划三步流水线:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
novel next
|
|
200
|
+
# volume:outline
|
|
201
|
+
|
|
202
|
+
novel instructions volume:outline --json
|
|
203
|
+
# 运行 PlotArchitect,写入 staging/volumes/vol-XX/**
|
|
204
|
+
|
|
205
|
+
novel validate volume:outline
|
|
206
|
+
novel advance volume:outline
|
|
207
|
+
|
|
208
|
+
novel next
|
|
209
|
+
# volume:validate
|
|
210
|
+
|
|
211
|
+
novel instructions volume:validate --json
|
|
212
|
+
novel validate volume:validate
|
|
213
|
+
novel advance volume:validate
|
|
214
|
+
|
|
215
|
+
novel next
|
|
216
|
+
# volume:commit
|
|
217
|
+
|
|
218
|
+
novel instructions volume:commit --json
|
|
219
|
+
novel commit --volume <N>
|
|
220
|
+
```
|
|
221
|
+
|
|
193
222
|
## 角色语气漂移(M7H.3,可选)
|
|
194
223
|
|
|
195
224
|
角色语气漂移用于:为关键角色建立“台词基线画像”,并在后续章节检测偏移,生成纠偏指令 `character-voice-drift.json`,直到恢复为止(自动清除)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "novel-writer-cli",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Executor-agnostic novel orchestration CLI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"homepage": "https://github.com/DankerMu/novel-writer-cli#readme",
|
|
30
30
|
"scripts": {
|
|
31
31
|
"dev": "tsx src/cli.ts",
|
|
32
|
-
"
|
|
32
|
+
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
33
|
+
"build": "npm run clean && tsc -p tsconfig.json",
|
|
33
34
|
"prepack": "npm run build",
|
|
34
35
|
"start": "node dist/cli.js",
|
|
35
36
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
@@ -133,6 +133,11 @@
|
|
|
133
133
|
"properties": {
|
|
134
134
|
"genre_drive_type": { "$ref": "#/$defs/genre_drive_type" },
|
|
135
135
|
"weight_profile_id": { "type": "string", "minLength": 1 },
|
|
136
|
+
"max_revisions": {
|
|
137
|
+
"type": "integer",
|
|
138
|
+
"minimum": 0,
|
|
139
|
+
"description": "Max gate-driven revision/polish loops before forcing progression."
|
|
140
|
+
},
|
|
136
141
|
"weight_overrides": {
|
|
137
142
|
"type": "object",
|
|
138
143
|
"description": "Optional per-dimension weight overrides (dimension_name -> weight).",
|