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,112 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, mkdir, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { advanceCheckpointForStep } from "../advance.js";
|
|
7
|
+
import { computeNextStep } from "../next-step.js";
|
|
8
|
+
async function writeJson(absPath, payload) {
|
|
9
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
10
|
+
await writeFile(absPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
11
|
+
}
|
|
12
|
+
async function exists(absPath) {
|
|
13
|
+
try {
|
|
14
|
+
await stat(absPath);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
test("computeNextStep progresses through volume review phases based on artifacts", async () => {
|
|
22
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-vol-review-phases-"));
|
|
23
|
+
// 1) no artifacts => collect
|
|
24
|
+
let next = await computeNextStep(rootDir, {
|
|
25
|
+
last_completed_chapter: 10,
|
|
26
|
+
current_volume: 1,
|
|
27
|
+
orchestrator_state: "VOL_REVIEW",
|
|
28
|
+
pipeline_stage: "committed",
|
|
29
|
+
inflight_chapter: null
|
|
30
|
+
});
|
|
31
|
+
assert.equal(next.step, "review:collect");
|
|
32
|
+
// 2) quality summary exists => audit
|
|
33
|
+
await mkdir(join(rootDir, "staging/vol-review"), { recursive: true });
|
|
34
|
+
await writeJson(join(rootDir, "staging/vol-review/quality-summary.json"), { schema_version: 1, generated_at: new Date().toISOString() });
|
|
35
|
+
next = await computeNextStep(rootDir, {
|
|
36
|
+
last_completed_chapter: 10,
|
|
37
|
+
current_volume: 1,
|
|
38
|
+
orchestrator_state: "VOL_REVIEW",
|
|
39
|
+
pipeline_stage: "committed",
|
|
40
|
+
inflight_chapter: null
|
|
41
|
+
});
|
|
42
|
+
assert.equal(next.step, "review:audit");
|
|
43
|
+
// 3) audit report exists => report
|
|
44
|
+
await writeJson(join(rootDir, "staging/vol-review/audit-report.json"), { schema_version: 1, generated_at: new Date().toISOString(), stats: {} });
|
|
45
|
+
next = await computeNextStep(rootDir, {
|
|
46
|
+
last_completed_chapter: 10,
|
|
47
|
+
current_volume: 1,
|
|
48
|
+
orchestrator_state: "VOL_REVIEW",
|
|
49
|
+
pipeline_stage: "committed",
|
|
50
|
+
inflight_chapter: null
|
|
51
|
+
});
|
|
52
|
+
assert.equal(next.step, "review:report");
|
|
53
|
+
// 4) review report exists => cleanup
|
|
54
|
+
await writeFile(join(rootDir, "staging/vol-review/review-report.md"), "# report\n", "utf8");
|
|
55
|
+
next = await computeNextStep(rootDir, {
|
|
56
|
+
last_completed_chapter: 10,
|
|
57
|
+
current_volume: 1,
|
|
58
|
+
orchestrator_state: "VOL_REVIEW",
|
|
59
|
+
pipeline_stage: "committed",
|
|
60
|
+
inflight_chapter: null
|
|
61
|
+
});
|
|
62
|
+
assert.equal(next.step, "review:cleanup");
|
|
63
|
+
// 5) foreshadow status exists => transition
|
|
64
|
+
await writeJson(join(rootDir, "staging/vol-review/foreshadow-status.json"), { schema_version: 1, generated_at: new Date().toISOString() });
|
|
65
|
+
next = await computeNextStep(rootDir, {
|
|
66
|
+
last_completed_chapter: 10,
|
|
67
|
+
current_volume: 1,
|
|
68
|
+
orchestrator_state: "VOL_REVIEW",
|
|
69
|
+
pipeline_stage: "committed",
|
|
70
|
+
inflight_chapter: null
|
|
71
|
+
});
|
|
72
|
+
assert.equal(next.step, "review:transition");
|
|
73
|
+
});
|
|
74
|
+
test("advanceCheckpointForStep(review:transition) increments volume, returns to WRITING, and clears review artifacts", async () => {
|
|
75
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-vol-review-transition-"));
|
|
76
|
+
await mkdir(join(rootDir, "staging/vol-review"), { recursive: true });
|
|
77
|
+
// checkpoint
|
|
78
|
+
await writeFile(join(rootDir, ".checkpoint.json"), `${JSON.stringify({ last_completed_chapter: 10, current_volume: 1, orchestrator_state: "VOL_REVIEW", pipeline_stage: "committed", inflight_chapter: null }, null, 2)}\n`, "utf8");
|
|
79
|
+
// required artifacts for transition validation
|
|
80
|
+
await writeJson(join(rootDir, "staging/vol-review/quality-summary.json"), { schema_version: 1, generated_at: new Date().toISOString() });
|
|
81
|
+
await writeJson(join(rootDir, "staging/vol-review/audit-report.json"), { schema_version: 1, generated_at: new Date().toISOString(), stats: {} });
|
|
82
|
+
await writeFile(join(rootDir, "staging/vol-review/review-report.md"), "# report\n", "utf8");
|
|
83
|
+
await writeJson(join(rootDir, "staging/vol-review/foreshadow-status.json"), { schema_version: 1, generated_at: new Date().toISOString() });
|
|
84
|
+
const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "review", phase: "transition" } });
|
|
85
|
+
assert.equal(updated.current_volume, 2);
|
|
86
|
+
assert.equal(updated.orchestrator_state, "WRITING");
|
|
87
|
+
assert.equal(updated.pipeline_stage, "committed");
|
|
88
|
+
assert.equal(updated.inflight_chapter, null);
|
|
89
|
+
assert.equal(updated.revision_count, 0);
|
|
90
|
+
assert.equal(updated.hook_fix_count, 0);
|
|
91
|
+
assert.equal(updated.title_fix_count, 0);
|
|
92
|
+
assert.equal(await exists(join(rootDir, "staging/vol-review")), false);
|
|
93
|
+
});
|
|
94
|
+
test("advanceCheckpointForStep refuses to advance review steps from CHAPTER_REWRITE", async () => {
|
|
95
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-vol-review-guard-"));
|
|
96
|
+
await writeFile(join(rootDir, ".checkpoint.json"), `${JSON.stringify({ last_completed_chapter: 10, current_volume: 1, orchestrator_state: "CHAPTER_REWRITE", pipeline_stage: "revising", inflight_chapter: 11 }, null, 2)}\n`, "utf8");
|
|
97
|
+
await assert.rejects(() => advanceCheckpointForStep({ rootDir, step: { kind: "review", phase: "collect" } }), /Refusing to advance review step from orchestrator_state=CHAPTER_REWRITE/);
|
|
98
|
+
});
|
|
99
|
+
test("computeNextStep enters volume review after committing volume-end chapter when outline range matches", async () => {
|
|
100
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-vol-review-enter-"));
|
|
101
|
+
await mkdir(join(rootDir, "volumes/vol-01"), { recursive: true });
|
|
102
|
+
await writeFile(join(rootDir, "volumes/vol-01/outline.md"), "### 第 1 章\n\n### 第 2 章\n", "utf8");
|
|
103
|
+
const next = await computeNextStep(rootDir, {
|
|
104
|
+
last_completed_chapter: 2,
|
|
105
|
+
current_volume: 1,
|
|
106
|
+
orchestrator_state: "WRITING",
|
|
107
|
+
pipeline_stage: "committed",
|
|
108
|
+
inflight_chapter: null
|
|
109
|
+
});
|
|
110
|
+
assert.equal(next.step, "review:collect");
|
|
111
|
+
assert.equal(next.reason, "volume_end:vol_review:missing_quality_summary");
|
|
112
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { computeStorylineRhythm } from "../volume-review.js";
|
|
7
|
+
test("computeStorylineRhythm counts multiple storyline_id entries per chapter (unique per chapter)", async () => {
|
|
8
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-storyline-rhythm-"));
|
|
9
|
+
await mkdir(join(rootDir, "summaries"), { recursive: true });
|
|
10
|
+
await writeFile(join(rootDir, "summaries/chapter-001-summary.md"), `storyline_id: main\nstoryline_id: side\nstoryline_id: side\n`, "utf8");
|
|
11
|
+
await writeFile(join(rootDir, "summaries/chapter-002-summary.md"), `storyline_id: side\n`, "utf8");
|
|
12
|
+
const res = await computeStorylineRhythm({ rootDir, volume: 1, chapter_range: [1, 2] });
|
|
13
|
+
const obj = res;
|
|
14
|
+
assert.equal(obj.schema_version, 1);
|
|
15
|
+
assert.deepEqual(obj.appearances, { main: 1, side: 2 });
|
|
16
|
+
assert.deepEqual(obj.last_seen, { main: 1, side: 2 });
|
|
17
|
+
assert.ok(Array.isArray(obj.warnings));
|
|
18
|
+
assert.ok(obj.warnings.some((w) => w.includes("Missing optional file: volumes/vol-01/storyline-schedule.json")));
|
|
19
|
+
});
|
package/dist/advance.js
CHANGED
|
@@ -3,11 +3,10 @@ import { readCheckpoint, writeCheckpoint } from "./checkpoint.js";
|
|
|
3
3
|
import { NovelCliError } from "./errors.js";
|
|
4
4
|
import { removePath } from "./fs-utils.js";
|
|
5
5
|
import { withWriteLock } from "./lock.js";
|
|
6
|
-
import { chapterRelPaths, titleFixSnapshotRel } from "./steps.js";
|
|
6
|
+
import { chapterRelPaths, formatStepId, titleFixSnapshotRel } from "./steps.js";
|
|
7
7
|
import { validateStep } from "./validate.js";
|
|
8
|
+
import { VOL_REVIEW_RELS } from "./volume-review.js";
|
|
8
9
|
function stageForStep(step) {
|
|
9
|
-
if (step.kind !== "chapter")
|
|
10
|
-
throw new NovelCliError(`Unsupported step kind: ${step.kind}`, 2);
|
|
11
10
|
switch (step.stage) {
|
|
12
11
|
case "draft":
|
|
13
12
|
return "drafting";
|
|
@@ -21,55 +20,153 @@ function stageForStep(step) {
|
|
|
21
20
|
return "refined";
|
|
22
21
|
case "hook-fix":
|
|
23
22
|
return "refined";
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
case "review":
|
|
24
|
+
case "commit":
|
|
25
|
+
throw new NovelCliError(`Unsupported step stage for advance: ${step.stage}`, 2);
|
|
26
|
+
default: {
|
|
27
|
+
const _exhaustive = step.stage;
|
|
28
|
+
throw new NovelCliError(`Unsupported step stage: ${_exhaustive}`, 2);
|
|
29
|
+
}
|
|
26
30
|
}
|
|
27
31
|
}
|
|
28
32
|
export async function advanceCheckpointForStep(args) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
33
|
+
const step = args.step;
|
|
34
|
+
if (step.kind === "chapter") {
|
|
35
|
+
if (step.stage === "commit")
|
|
36
|
+
throw new NovelCliError(`Use 'novel commit' for commit.`, 2);
|
|
37
|
+
if (step.stage === "review")
|
|
38
|
+
throw new NovelCliError(`Review is a manual step; do not advance it.`, 2);
|
|
39
|
+
return await withWriteLock(args.rootDir, { chapter: step.chapter }, async () => {
|
|
40
|
+
const checkpoint = await readCheckpoint(args.rootDir);
|
|
41
|
+
// Enforce validate-before-advance to keep deterministic semantics.
|
|
42
|
+
await validateStep({ rootDir: args.rootDir, checkpoint, step });
|
|
43
|
+
const updated = { ...checkpoint };
|
|
44
|
+
const nextStage = stageForStep(step);
|
|
45
|
+
updated.pipeline_stage = nextStage;
|
|
46
|
+
updated.inflight_chapter = step.chapter;
|
|
47
|
+
updated.orchestrator_state =
|
|
48
|
+
checkpoint.orchestrator_state === "CHAPTER_REWRITE" || checkpoint.pipeline_stage === "revising" ? "CHAPTER_REWRITE" : "WRITING";
|
|
49
|
+
// Ensure revision counter is initialized when starting from draft (revision loops may preserve it).
|
|
50
|
+
if (step.stage === "draft") {
|
|
51
|
+
if (typeof updated.revision_count !== "number")
|
|
52
|
+
updated.revision_count = 0;
|
|
53
|
+
updated.hook_fix_count = 0;
|
|
54
|
+
updated.title_fix_count = 0;
|
|
55
|
+
await removePath(join(args.rootDir, titleFixSnapshotRel(step.chapter)));
|
|
56
|
+
// If rewinding from a later stage, clear downstream staging artifacts to avoid skipping steps with stale data.
|
|
57
|
+
const prevStage = checkpoint.pipeline_stage ?? null;
|
|
58
|
+
const prevInflight = typeof checkpoint.inflight_chapter === "number" ? checkpoint.inflight_chapter : null;
|
|
59
|
+
if (prevInflight === step.chapter && prevStage && prevStage !== "drafting") {
|
|
60
|
+
const rel = chapterRelPaths(step.chapter);
|
|
61
|
+
await removePath(join(args.rootDir, rel.staging.summaryMd));
|
|
62
|
+
await removePath(join(args.rootDir, rel.staging.deltaJson));
|
|
63
|
+
await removePath(join(args.rootDir, rel.staging.crossrefJson));
|
|
64
|
+
await removePath(join(args.rootDir, rel.staging.evalJson));
|
|
65
|
+
await removePath(join(args.rootDir, rel.staging.styleRefinerChangesJson));
|
|
66
|
+
// Revision loops are driven by gate decision after judge.
|
|
67
|
+
if (prevStage === "judged") {
|
|
68
|
+
const prev = typeof updated.revision_count === "number" ? updated.revision_count : 0;
|
|
69
|
+
updated.revision_count = prev + 1;
|
|
70
|
+
updated.orchestrator_state = "CHAPTER_REWRITE";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Title-fix counts as a bounded micro-revision and invalidates the current eval.
|
|
75
|
+
if (step.stage === "title-fix") {
|
|
76
|
+
const prev = typeof updated.title_fix_count === "number" ? updated.title_fix_count : 0;
|
|
77
|
+
if (prev >= 1) {
|
|
78
|
+
throw new NovelCliError(`Title-fix already attempted for chapter ${step.chapter}; manual review required.`, 2);
|
|
79
|
+
}
|
|
80
|
+
updated.title_fix_count = prev + 1;
|
|
81
|
+
const rel = chapterRelPaths(step.chapter);
|
|
82
|
+
await removePath(join(args.rootDir, rel.staging.evalJson));
|
|
83
|
+
}
|
|
84
|
+
// Hook-fix counts as a bounded micro-revision and invalidates the current eval.
|
|
85
|
+
if (step.stage === "hook-fix") {
|
|
86
|
+
const prev = typeof updated.hook_fix_count === "number" ? updated.hook_fix_count : 0;
|
|
87
|
+
if (prev >= 1) {
|
|
88
|
+
throw new NovelCliError(`Hook-fix already attempted for chapter ${step.chapter}; manual review required.`, 2);
|
|
89
|
+
}
|
|
90
|
+
updated.hook_fix_count = prev + 1;
|
|
91
|
+
const rel = chapterRelPaths(step.chapter);
|
|
92
|
+
await removePath(join(args.rootDir, rel.staging.evalJson));
|
|
93
|
+
}
|
|
94
|
+
// Refine rewrites the chapter draft; invalidate prior eval to force re-judge.
|
|
95
|
+
if (step.stage === "refine") {
|
|
96
|
+
const rel = chapterRelPaths(step.chapter);
|
|
97
|
+
await removePath(join(args.rootDir, rel.staging.evalJson));
|
|
98
|
+
// If we're polishing after a judged gate decision, count it as a revision loop.
|
|
99
|
+
const prevStage = checkpoint.pipeline_stage ?? null;
|
|
100
|
+
const prevInflight = typeof checkpoint.inflight_chapter === "number" ? checkpoint.inflight_chapter : null;
|
|
101
|
+
if (prevInflight === step.chapter && prevStage === "judged") {
|
|
102
|
+
const prev = typeof updated.revision_count === "number" ? updated.revision_count : 0;
|
|
103
|
+
updated.revision_count = prev + 1;
|
|
104
|
+
updated.orchestrator_state = "CHAPTER_REWRITE";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
updated.last_checkpoint_time = new Date().toISOString();
|
|
108
|
+
await writeCheckpoint(args.rootDir, updated);
|
|
109
|
+
return updated;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (step.kind === "review") {
|
|
113
|
+
const reviewStep = step;
|
|
114
|
+
return await withWriteLock(args.rootDir, {}, async () => {
|
|
115
|
+
const checkpoint = await readCheckpoint(args.rootDir);
|
|
116
|
+
if (checkpoint.orchestrator_state !== "WRITING" && checkpoint.orchestrator_state !== "VOL_REVIEW") {
|
|
117
|
+
throw new NovelCliError(`Refusing to advance review step from orchestrator_state=${checkpoint.orchestrator_state}. Expected WRITING (volume_end) or VOL_REVIEW.`, 2);
|
|
118
|
+
}
|
|
119
|
+
// Enforce validate-before-advance to keep deterministic semantics.
|
|
120
|
+
await validateStep({ rootDir: args.rootDir, checkpoint, step: reviewStep });
|
|
121
|
+
const updated = { ...checkpoint };
|
|
122
|
+
updated.inflight_chapter = null;
|
|
123
|
+
if (reviewStep.phase === "transition") {
|
|
124
|
+
updated.current_volume = checkpoint.current_volume + 1;
|
|
125
|
+
updated.orchestrator_state = "WRITING";
|
|
126
|
+
updated.pipeline_stage = "committed";
|
|
46
127
|
updated.revision_count = 0;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
// Title-fix counts as a bounded micro-revision and invalidates the current eval.
|
|
52
|
-
if (args.step.stage === "title-fix") {
|
|
53
|
-
const prev = typeof updated.title_fix_count === "number" ? updated.title_fix_count : 0;
|
|
54
|
-
if (prev >= 1) {
|
|
55
|
-
throw new NovelCliError(`Title-fix already attempted for chapter ${args.step.chapter}; manual review required.`, 2);
|
|
128
|
+
updated.hook_fix_count = 0;
|
|
129
|
+
updated.title_fix_count = 0;
|
|
130
|
+
await removePath(join(args.rootDir, VOL_REVIEW_RELS.dir));
|
|
56
131
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
await removePath(join(args.rootDir, rel.staging.evalJson));
|
|
60
|
-
}
|
|
61
|
-
// Hook-fix counts as a bounded micro-revision and invalidates the current eval.
|
|
62
|
-
if (args.step.stage === "hook-fix") {
|
|
63
|
-
const prev = typeof updated.hook_fix_count === "number" ? updated.hook_fix_count : 0;
|
|
64
|
-
if (prev >= 1) {
|
|
65
|
-
throw new NovelCliError(`Hook-fix already attempted for chapter ${args.step.chapter}; manual review required.`, 2);
|
|
132
|
+
else {
|
|
133
|
+
updated.orchestrator_state = "VOL_REVIEW";
|
|
66
134
|
}
|
|
67
|
-
updated.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return
|
|
74
|
-
|
|
135
|
+
updated.last_checkpoint_time = new Date().toISOString();
|
|
136
|
+
await writeCheckpoint(args.rootDir, updated);
|
|
137
|
+
return updated;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (step.kind === "volume") {
|
|
141
|
+
return await withWriteLock(args.rootDir, {}, async () => {
|
|
142
|
+
const checkpoint = await readCheckpoint(args.rootDir);
|
|
143
|
+
if (checkpoint.orchestrator_state !== "VOL_PLANNING") {
|
|
144
|
+
throw new NovelCliError(`Cannot advance ${formatStepId(step)} unless orchestrator_state=VOL_PLANNING.`, 2);
|
|
145
|
+
}
|
|
146
|
+
await validateStep({ rootDir: args.rootDir, checkpoint, step });
|
|
147
|
+
const updated = { ...checkpoint };
|
|
148
|
+
updated.orchestrator_state = "VOL_PLANNING";
|
|
149
|
+
updated.pipeline_stage = "committed";
|
|
150
|
+
updated.inflight_chapter = null;
|
|
151
|
+
const phase = step.phase;
|
|
152
|
+
switch (phase) {
|
|
153
|
+
case "outline":
|
|
154
|
+
updated.volume_pipeline_stage = "validate";
|
|
155
|
+
break;
|
|
156
|
+
case "validate":
|
|
157
|
+
updated.volume_pipeline_stage = "commit";
|
|
158
|
+
break;
|
|
159
|
+
case "commit":
|
|
160
|
+
throw new NovelCliError(`Use 'novel commit --volume <n>' for volume commit.`, 2);
|
|
161
|
+
default: {
|
|
162
|
+
const _exhaustive = phase;
|
|
163
|
+
throw new NovelCliError(`Unsupported volume phase: ${String(_exhaustive)}`, 2);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
updated.last_checkpoint_time = new Date().toISOString();
|
|
167
|
+
await writeCheckpoint(args.rootDir, updated);
|
|
168
|
+
return updated;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
throw new NovelCliError(`Unsupported step kind: ${step.kind}`, 2);
|
|
75
172
|
}
|
package/dist/checkpoint.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
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"];
|
|
6
7
|
export function createDefaultCheckpoint(nowIso) {
|
|
7
8
|
return {
|
|
8
9
|
last_completed_chapter: 0,
|
|
9
10
|
current_volume: 1,
|
|
11
|
+
// TODO(CS-O3): Default to INIT once the quickstart pipeline is implemented.
|
|
12
|
+
orchestrator_state: "WRITING",
|
|
10
13
|
pipeline_stage: "committed",
|
|
14
|
+
volume_pipeline_stage: null,
|
|
11
15
|
inflight_chapter: null,
|
|
12
16
|
revision_count: 0,
|
|
13
17
|
hook_fix_count: 0,
|
|
@@ -34,6 +38,23 @@ function asNullableInt(value) {
|
|
|
34
38
|
return null;
|
|
35
39
|
return asInt(value);
|
|
36
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
|
+
}
|
|
37
58
|
function parseCheckpoint(data) {
|
|
38
59
|
if (!isPlainObject(data)) {
|
|
39
60
|
throw new NovelCliError(".checkpoint.json must be a JSON object.", 2);
|
|
@@ -43,12 +64,8 @@ function parseCheckpoint(data) {
|
|
|
43
64
|
throw new NovelCliError(".checkpoint.json.last_completed_chapter must be an int >= 0.", 2);
|
|
44
65
|
}
|
|
45
66
|
const currentVolume = asInt(data.current_volume);
|
|
46
|
-
if (currentVolume === null || currentVolume <
|
|
47
|
-
throw new NovelCliError(".checkpoint.json.current_volume must be an int >=
|
|
48
|
-
}
|
|
49
|
-
const orchestratorState = data.orchestrator_state;
|
|
50
|
-
if (orchestratorState !== undefined && asString(orchestratorState) === null) {
|
|
51
|
-
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);
|
|
52
69
|
}
|
|
53
70
|
const pipelineStageRaw = data.pipeline_stage;
|
|
54
71
|
let pipelineStage;
|
|
@@ -72,10 +89,36 @@ function parseCheckpoint(data) {
|
|
|
72
89
|
const inflightRaw = data.inflight_chapter;
|
|
73
90
|
const inflight = asNullableInt(inflightRaw);
|
|
74
91
|
if (inflightRaw !== undefined && inflight === null && inflightRaw !== null) {
|
|
75
|
-
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
|
+
}
|
|
76
112
|
}
|
|
77
|
-
|
|
78
|
-
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
|
+
}
|
|
79
122
|
}
|
|
80
123
|
const revision = data.revision_count;
|
|
81
124
|
if (revision !== undefined) {
|
|
@@ -106,15 +149,31 @@ function parseCheckpoint(data) {
|
|
|
106
149
|
if (lastTime !== undefined && asString(lastTime) === null) {
|
|
107
150
|
throw new NovelCliError(".checkpoint.json.last_checkpoint_time must be a string when present.", 2);
|
|
108
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
|
+
}
|
|
109
167
|
const checkpoint = {
|
|
110
168
|
...data,
|
|
111
169
|
last_completed_chapter: lastCompleted,
|
|
112
|
-
current_volume: currentVolume
|
|
170
|
+
current_volume: currentVolume,
|
|
171
|
+
orchestrator_state: orchestratorState
|
|
113
172
|
};
|
|
114
|
-
if (orchestratorState !== undefined)
|
|
115
|
-
checkpoint.orchestrator_state = orchestratorState;
|
|
116
173
|
if (pipelineStage !== undefined)
|
|
117
174
|
checkpoint.pipeline_stage = pipelineStage;
|
|
175
|
+
if (volumeStage !== undefined)
|
|
176
|
+
checkpoint.volume_pipeline_stage = volumeStage;
|
|
118
177
|
if (inflight !== undefined)
|
|
119
178
|
checkpoint.inflight_chapter = inflight;
|
|
120
179
|
return checkpoint;
|