novel-writer-cli 0.1.0 → 0.2.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.
- package/dist/__tests__/checkpoint-quickstart-phase.test.js +49 -0
- package/dist/__tests__/cli-instructions-novel-ask-gate.test.js +83 -0
- package/dist/__tests__/cli-repair-reset-quickstart.test.js +194 -0
- package/dist/__tests__/cli-version-flag.test.js +38 -0
- package/dist/__tests__/init.test.js +9 -6
- package/dist/__tests__/instructions-review-novel-ask-gate.test.js +31 -0
- package/dist/__tests__/orchestrator-state-routing.test.js +10 -6
- package/dist/__tests__/quickstart-pipeline.test.js +346 -0
- package/dist/__tests__/safe-path-symlink.test.js +41 -0
- package/dist/__tests__/validate-quickstart-prereqs.test.js +73 -0
- package/dist/advance.js +88 -3
- package/dist/checkpoint.js +25 -4
- package/dist/cli.js +130 -4
- package/dist/init.js +2 -1
- package/dist/instructions.js +162 -1
- package/dist/next-step.js +227 -4
- package/dist/quickstart-validators.js +84 -0
- package/dist/quickstart.js +16 -0
- package/dist/safe-path.js +23 -1
- package/dist/validate.js +72 -1
- package/docs/user/README.md +0 -1
- package/package.json +1 -1
- package/scripts/sync-final-spec-skills.mjs +65 -0
- package/skills/cli-step/SKILL.md +186 -32
- package/skills/continue/SKILL.md +30 -326
- package/skills/shared/thin-adapter-loop.md +67 -0
- package/skills/start/SKILL.md +23 -440
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, 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 { readCheckpoint } from "../checkpoint.js";
|
|
8
|
+
import { buildInstructionPacket } from "../instructions.js";
|
|
9
|
+
import { computeNextStep } from "../next-step.js";
|
|
10
|
+
async function writeText(absPath, contents) {
|
|
11
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
12
|
+
await writeFile(absPath, contents, "utf8");
|
|
13
|
+
}
|
|
14
|
+
async function writeJson(absPath, payload) {
|
|
15
|
+
await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
16
|
+
}
|
|
17
|
+
async function pathExists(absPath) {
|
|
18
|
+
try {
|
|
19
|
+
await stat(absPath);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
test("advance quickstart:world transitions INIT -> QUICK_START", async () => {
|
|
27
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-init-"));
|
|
28
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
29
|
+
last_completed_chapter: 0,
|
|
30
|
+
current_volume: 1,
|
|
31
|
+
orchestrator_state: "INIT",
|
|
32
|
+
pipeline_stage: null,
|
|
33
|
+
inflight_chapter: null
|
|
34
|
+
});
|
|
35
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), {
|
|
36
|
+
rules: [
|
|
37
|
+
{
|
|
38
|
+
id: "W-001",
|
|
39
|
+
category: "magic_system",
|
|
40
|
+
rule: "力量体系上限为九阶。",
|
|
41
|
+
constraint_type: "hard",
|
|
42
|
+
exceptions: [],
|
|
43
|
+
introduced_chapter: null,
|
|
44
|
+
last_verified: null
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
});
|
|
48
|
+
const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "world" } });
|
|
49
|
+
assert.equal(updated.orchestrator_state, "QUICK_START");
|
|
50
|
+
assert.equal(updated.quickstart_phase, "world");
|
|
51
|
+
const checkpoint = await readCheckpoint(rootDir);
|
|
52
|
+
assert.equal(checkpoint.orchestrator_state, "QUICK_START");
|
|
53
|
+
assert.equal(checkpoint.quickstart_phase, "world");
|
|
54
|
+
});
|
|
55
|
+
test("advance quickstart:characters writes quickstart_phase correctly", async () => {
|
|
56
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-characters-advance-"));
|
|
57
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
58
|
+
last_completed_chapter: 0,
|
|
59
|
+
current_volume: 1,
|
|
60
|
+
orchestrator_state: "QUICK_START",
|
|
61
|
+
pipeline_stage: null,
|
|
62
|
+
inflight_chapter: null
|
|
63
|
+
});
|
|
64
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
65
|
+
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
66
|
+
const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "characters" } });
|
|
67
|
+
assert.equal(updated.orchestrator_state, "QUICK_START");
|
|
68
|
+
assert.equal(updated.quickstart_phase, "characters");
|
|
69
|
+
const checkpoint = await readCheckpoint(rootDir);
|
|
70
|
+
assert.equal(checkpoint.orchestrator_state, "QUICK_START");
|
|
71
|
+
assert.equal(checkpoint.quickstart_phase, "characters");
|
|
72
|
+
});
|
|
73
|
+
test("advance quickstart:style writes quickstart_phase correctly", async () => {
|
|
74
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-style-advance-"));
|
|
75
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
76
|
+
last_completed_chapter: 0,
|
|
77
|
+
current_volume: 1,
|
|
78
|
+
orchestrator_state: "QUICK_START",
|
|
79
|
+
pipeline_stage: null,
|
|
80
|
+
inflight_chapter: null
|
|
81
|
+
});
|
|
82
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
83
|
+
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
84
|
+
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
85
|
+
const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "style" } });
|
|
86
|
+
assert.equal(updated.orchestrator_state, "QUICK_START");
|
|
87
|
+
assert.equal(updated.quickstart_phase, "style");
|
|
88
|
+
const checkpoint = await readCheckpoint(rootDir);
|
|
89
|
+
assert.equal(checkpoint.orchestrator_state, "QUICK_START");
|
|
90
|
+
assert.equal(checkpoint.quickstart_phase, "style");
|
|
91
|
+
});
|
|
92
|
+
test("advance quickstart:trial writes quickstart_phase correctly", async () => {
|
|
93
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-trial-advance-"));
|
|
94
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
95
|
+
last_completed_chapter: 0,
|
|
96
|
+
current_volume: 1,
|
|
97
|
+
orchestrator_state: "QUICK_START",
|
|
98
|
+
pipeline_stage: null,
|
|
99
|
+
inflight_chapter: null
|
|
100
|
+
});
|
|
101
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
102
|
+
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
103
|
+
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
104
|
+
await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), "# Trial\n\nText\n");
|
|
105
|
+
const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "trial" } });
|
|
106
|
+
assert.equal(updated.orchestrator_state, "QUICK_START");
|
|
107
|
+
assert.equal(updated.quickstart_phase, "trial");
|
|
108
|
+
const checkpoint = await readCheckpoint(rootDir);
|
|
109
|
+
assert.equal(checkpoint.orchestrator_state, "QUICK_START");
|
|
110
|
+
assert.equal(checkpoint.quickstart_phase, "trial");
|
|
111
|
+
});
|
|
112
|
+
test("advance quickstart rejects wrong orchestrator_state", async () => {
|
|
113
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-advance-wrong-state-"));
|
|
114
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
115
|
+
last_completed_chapter: 0,
|
|
116
|
+
current_volume: 1,
|
|
117
|
+
orchestrator_state: "WRITING",
|
|
118
|
+
pipeline_stage: null,
|
|
119
|
+
inflight_chapter: null
|
|
120
|
+
});
|
|
121
|
+
await assert.rejects(() => advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "world" } }), /Cannot advance quickstart:world unless orchestrator_state=INIT or QUICK_START/);
|
|
122
|
+
});
|
|
123
|
+
test("computeNextStep recovers quickstart phase from staging artifacts", async () => {
|
|
124
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-resume-"));
|
|
125
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
126
|
+
last_completed_chapter: 0,
|
|
127
|
+
current_volume: 1,
|
|
128
|
+
orchestrator_state: "QUICK_START",
|
|
129
|
+
pipeline_stage: null,
|
|
130
|
+
inflight_chapter: null
|
|
131
|
+
});
|
|
132
|
+
// No artifacts yet → world
|
|
133
|
+
let next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
134
|
+
assert.equal(next.step, "quickstart:world");
|
|
135
|
+
// rules.json present → characters
|
|
136
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
137
|
+
next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
138
|
+
assert.equal(next.step, "quickstart:characters");
|
|
139
|
+
// contracts dir + one contract → style
|
|
140
|
+
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
141
|
+
next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
142
|
+
assert.equal(next.step, "quickstart:style");
|
|
143
|
+
// style profile present → trial
|
|
144
|
+
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
145
|
+
next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
146
|
+
assert.equal(next.step, "quickstart:trial");
|
|
147
|
+
// trial chapter present → results
|
|
148
|
+
await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), `# 试写章\n\n(测试)\n`);
|
|
149
|
+
next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
150
|
+
assert.equal(next.step, "quickstart:results");
|
|
151
|
+
assert.equal(next.reason, "quickstart:results");
|
|
152
|
+
// evaluation present → results (ready to advance/commit)
|
|
153
|
+
await writeJson(join(rootDir, "staging/quickstart/evaluation.json"), { overall: 4.2, recommendation: "pass" });
|
|
154
|
+
next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
155
|
+
assert.equal(next.step, "quickstart:results");
|
|
156
|
+
assert.equal(next.reason, "quickstart:results:artifacts_present");
|
|
157
|
+
});
|
|
158
|
+
test("computeNextStep allows redoing current quickstart phase when artifacts are missing", async () => {
|
|
159
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-recover-checkpoint-"));
|
|
160
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
161
|
+
last_completed_chapter: 0,
|
|
162
|
+
current_volume: 1,
|
|
163
|
+
orchestrator_state: "QUICK_START",
|
|
164
|
+
pipeline_stage: null,
|
|
165
|
+
inflight_chapter: null,
|
|
166
|
+
quickstart_phase: "world"
|
|
167
|
+
});
|
|
168
|
+
const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
169
|
+
assert.equal(next.step, "quickstart:world");
|
|
170
|
+
assert.equal(next.reason, "quickstart:world");
|
|
171
|
+
});
|
|
172
|
+
test("computeNextStep blocks quickstart rollback when quickstart_phase=characters but world artifacts are missing", async () => {
|
|
173
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-recover-characters-"));
|
|
174
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
175
|
+
last_completed_chapter: 0,
|
|
176
|
+
current_volume: 1,
|
|
177
|
+
orchestrator_state: "QUICK_START",
|
|
178
|
+
pipeline_stage: null,
|
|
179
|
+
inflight_chapter: null,
|
|
180
|
+
quickstart_phase: "characters"
|
|
181
|
+
});
|
|
182
|
+
const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
183
|
+
assert.equal(next.step, "quickstart:world");
|
|
184
|
+
assert.match(next.reason, /quickstart:recovery_blocked/);
|
|
185
|
+
assert.equal(next.evidence.recovery_blocked.checkpoint_phase, "characters");
|
|
186
|
+
assert.equal(next.evidence.recovery_blocked.inferred_phase, "world");
|
|
187
|
+
assert.equal(next.evidence.recovery_blocked.expected_path, "staging/quickstart/rules.json");
|
|
188
|
+
});
|
|
189
|
+
test("computeNextStep allows redoing style phase when style profile is missing", async () => {
|
|
190
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-recover-style-"));
|
|
191
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
192
|
+
last_completed_chapter: 0,
|
|
193
|
+
current_volume: 1,
|
|
194
|
+
orchestrator_state: "QUICK_START",
|
|
195
|
+
pipeline_stage: null,
|
|
196
|
+
inflight_chapter: null,
|
|
197
|
+
quickstart_phase: "style"
|
|
198
|
+
});
|
|
199
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
200
|
+
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
201
|
+
const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
202
|
+
assert.equal(next.step, "quickstart:style");
|
|
203
|
+
assert.equal(next.reason, "quickstart:style");
|
|
204
|
+
});
|
|
205
|
+
test("computeNextStep allows redoing trial phase when trial chapter is missing", async () => {
|
|
206
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-recover-trial-"));
|
|
207
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
208
|
+
last_completed_chapter: 0,
|
|
209
|
+
current_volume: 1,
|
|
210
|
+
orchestrator_state: "QUICK_START",
|
|
211
|
+
pipeline_stage: null,
|
|
212
|
+
inflight_chapter: null,
|
|
213
|
+
quickstart_phase: "trial"
|
|
214
|
+
});
|
|
215
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
216
|
+
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
217
|
+
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
218
|
+
const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
219
|
+
assert.equal(next.step, "quickstart:trial");
|
|
220
|
+
assert.equal(next.reason, "quickstart:trial");
|
|
221
|
+
});
|
|
222
|
+
test("computeNextStep continues forward when checkpoint quickstart_phase is consistent with staging artifacts", async () => {
|
|
223
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-recover-happy-"));
|
|
224
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
225
|
+
last_completed_chapter: 0,
|
|
226
|
+
current_volume: 1,
|
|
227
|
+
orchestrator_state: "QUICK_START",
|
|
228
|
+
pipeline_stage: null,
|
|
229
|
+
inflight_chapter: null,
|
|
230
|
+
quickstart_phase: "style"
|
|
231
|
+
});
|
|
232
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
233
|
+
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
234
|
+
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
235
|
+
const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
236
|
+
assert.equal(next.step, "quickstart:trial");
|
|
237
|
+
assert.equal(next.reason, "quickstart:trial");
|
|
238
|
+
assert.equal(next.evidence.recovery_blocked ?? null, null);
|
|
239
|
+
});
|
|
240
|
+
test("buildInstructionPacket (quickstart) includes NOVEL_ASK gate when provided", async () => {
|
|
241
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-novel-ask-"));
|
|
242
|
+
const questionSpec = {
|
|
243
|
+
version: 1,
|
|
244
|
+
topic: "quickstart_gate",
|
|
245
|
+
questions: [
|
|
246
|
+
{
|
|
247
|
+
id: "genre",
|
|
248
|
+
header: "Genre",
|
|
249
|
+
question: "Pick a genre.",
|
|
250
|
+
kind: "single_choice",
|
|
251
|
+
required: true,
|
|
252
|
+
options: [{ label: "xuanhuan", description: "玄幻" }]
|
|
253
|
+
}
|
|
254
|
+
]
|
|
255
|
+
};
|
|
256
|
+
const answerPath = "staging/novel-ask/quickstart.json";
|
|
257
|
+
const built = (await buildInstructionPacket({
|
|
258
|
+
rootDir,
|
|
259
|
+
checkpoint: { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "INIT" },
|
|
260
|
+
step: { kind: "quickstart", phase: "world" },
|
|
261
|
+
embedMode: null,
|
|
262
|
+
writeManifest: false,
|
|
263
|
+
novelAskGate: { novel_ask: questionSpec, answer_path: answerPath }
|
|
264
|
+
}));
|
|
265
|
+
assert.equal(built.packet.step, "quickstart:world");
|
|
266
|
+
assert.equal(built.packet.answer_path, answerPath);
|
|
267
|
+
assert.equal(built.packet.novel_ask.topic, questionSpec.topic);
|
|
268
|
+
assert.equal(built.packet.expected_outputs[0].path, answerPath);
|
|
269
|
+
});
|
|
270
|
+
test("advance quickstart:results commits artifacts and transitions to VOL_PLANNING", async () => {
|
|
271
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-commit-"));
|
|
272
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
273
|
+
last_completed_chapter: 0,
|
|
274
|
+
current_volume: 1,
|
|
275
|
+
orchestrator_state: "QUICK_START",
|
|
276
|
+
pipeline_stage: null,
|
|
277
|
+
inflight_chapter: null,
|
|
278
|
+
volume_pipeline_stage: null
|
|
279
|
+
});
|
|
280
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), {
|
|
281
|
+
rules: [
|
|
282
|
+
{
|
|
283
|
+
id: "W-001",
|
|
284
|
+
category: "magic_system",
|
|
285
|
+
rule: "力量体系上限为九阶。",
|
|
286
|
+
constraint_type: "hard",
|
|
287
|
+
exceptions: [],
|
|
288
|
+
introduced_chapter: null,
|
|
289
|
+
last_verified: null
|
|
290
|
+
}
|
|
291
|
+
]
|
|
292
|
+
});
|
|
293
|
+
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
294
|
+
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
295
|
+
await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), `# 试写章\n\n(测试)\n`);
|
|
296
|
+
await writeJson(join(rootDir, "staging/quickstart/evaluation.json"), { overall: 4.2, recommendation: "pass" });
|
|
297
|
+
const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "results" } });
|
|
298
|
+
assert.equal(updated.orchestrator_state, "VOL_PLANNING");
|
|
299
|
+
assert.equal(updated.volume_pipeline_stage, null);
|
|
300
|
+
assert.equal(updated.quickstart_phase ?? null, null);
|
|
301
|
+
// Staging quickstart cleared
|
|
302
|
+
assert.equal(await pathExists(join(rootDir, "staging/quickstart")), false);
|
|
303
|
+
// Final artifacts exist
|
|
304
|
+
assert.equal(await pathExists(join(rootDir, "world/rules.json")), true);
|
|
305
|
+
assert.equal(await pathExists(join(rootDir, "style-profile.json")), true);
|
|
306
|
+
assert.equal(await pathExists(join(rootDir, "characters/active/hero.json")), true);
|
|
307
|
+
assert.equal(await pathExists(join(rootDir, "logs/quickstart/trial-chapter.md")), true);
|
|
308
|
+
assert.equal(await pathExists(join(rootDir, "logs/quickstart/evaluation.json")), true);
|
|
309
|
+
// Pipeline now in volume planning and should not re-enter quickstart.
|
|
310
|
+
const next = await computeNextStep(rootDir, await readCheckpoint(rootDir));
|
|
311
|
+
assert.equal(next.step, "volume:outline");
|
|
312
|
+
});
|
|
313
|
+
test("advance quickstart:results validates all contracts (not just a slice)", async () => {
|
|
314
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-quickstart-contracts-all-"));
|
|
315
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
316
|
+
last_completed_chapter: 0,
|
|
317
|
+
current_volume: 1,
|
|
318
|
+
orchestrator_state: "QUICK_START",
|
|
319
|
+
pipeline_stage: null,
|
|
320
|
+
inflight_chapter: null,
|
|
321
|
+
volume_pipeline_stage: null
|
|
322
|
+
});
|
|
323
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), {
|
|
324
|
+
rules: [
|
|
325
|
+
{
|
|
326
|
+
id: "W-001",
|
|
327
|
+
category: "magic_system",
|
|
328
|
+
rule: "力量体系上限为九阶。",
|
|
329
|
+
constraint_type: "hard",
|
|
330
|
+
exceptions: [],
|
|
331
|
+
introduced_chapter: null,
|
|
332
|
+
last_verified: null
|
|
333
|
+
}
|
|
334
|
+
]
|
|
335
|
+
});
|
|
336
|
+
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
337
|
+
await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), `# 试写章\n\n(测试)\n`);
|
|
338
|
+
await writeJson(join(rootDir, "staging/quickstart/evaluation.json"), { overall: 4.2, recommendation: "pass" });
|
|
339
|
+
for (let i = 1; i <= 10; i++) {
|
|
340
|
+
const id = String(i).padStart(2, "0");
|
|
341
|
+
await writeJson(join(rootDir, `staging/quickstart/contracts/contract-${id}.json`), { id: `c-${id}`, display_name: `角色${id}`, contracts: [] });
|
|
342
|
+
}
|
|
343
|
+
// 11th contract is invalid: validate:results must still catch it.
|
|
344
|
+
await writeJson(join(rootDir, "staging/quickstart/contracts/contract-11.json"), []);
|
|
345
|
+
await assert.rejects(() => advanceCheckpointForStep({ rootDir, step: { kind: "quickstart", phase: "results" } }), /Invalid contract JSON/);
|
|
346
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, symlink, 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 { resolveProjectRelativePath } from "../safe-path.js";
|
|
7
|
+
test("resolveProjectRelativePath rejects symlink escapes outside project root", async (t) => {
|
|
8
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-safe-path-root-"));
|
|
9
|
+
const outsideDir = await mkdtemp(join(tmpdir(), "novel-safe-path-outside-"));
|
|
10
|
+
await writeFile(join(outsideDir, "payload.json"), "{}\n", "utf8");
|
|
11
|
+
await mkdir(join(rootDir, "staging"), { recursive: true });
|
|
12
|
+
try {
|
|
13
|
+
await symlink(outsideDir, join(rootDir, "staging/evil"), "dir");
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
const code = err instanceof Error ? err.code : null;
|
|
17
|
+
if (code === "EPERM" || code === "EACCES" || code === "ENOTSUP" || code === "ENOSYS") {
|
|
18
|
+
t.skip(`symlink not permitted in this environment: ${code}`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
assert.throws(() => resolveProjectRelativePath(rootDir, "staging/evil/payload.json", "testPath"), /Unsafe path outside project root/);
|
|
24
|
+
});
|
|
25
|
+
test("resolveProjectRelativePath rejects symlink escapes for non-existent write targets", async (t) => {
|
|
26
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-safe-path-root-write-"));
|
|
27
|
+
const outsideDir = await mkdtemp(join(tmpdir(), "novel-safe-path-outside-write-"));
|
|
28
|
+
await mkdir(join(rootDir, "staging"), { recursive: true });
|
|
29
|
+
try {
|
|
30
|
+
await symlink(outsideDir, join(rootDir, "staging/evil"), "dir");
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
const code = err instanceof Error ? err.code : null;
|
|
34
|
+
if (code === "EPERM" || code === "EACCES" || code === "ENOTSUP" || code === "ENOSYS") {
|
|
35
|
+
t.skip(`symlink not permitted in this environment: ${code}`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
assert.throws(() => resolveProjectRelativePath(rootDir, "staging/evil/subdir/newfile.json", "testPath"), /Unsafe path outside project root/);
|
|
41
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, 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 { validateStep } from "../validate.js";
|
|
7
|
+
async function writeText(absPath, contents) {
|
|
8
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
9
|
+
await writeFile(absPath, contents, "utf8");
|
|
10
|
+
}
|
|
11
|
+
async function writeJson(absPath, payload) {
|
|
12
|
+
await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
13
|
+
}
|
|
14
|
+
test("validateStep(quickstart:characters) requires rules.json", async () => {
|
|
15
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-validate-quickstart-characters-"));
|
|
16
|
+
await assert.rejects(() => validateStep({
|
|
17
|
+
rootDir,
|
|
18
|
+
checkpoint: { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "QUICK_START" },
|
|
19
|
+
step: { kind: "quickstart", phase: "characters" }
|
|
20
|
+
}), /Missing required file: staging\/quickstart\/rules\.json/);
|
|
21
|
+
});
|
|
22
|
+
test("validateStep(quickstart:style) requires contracts dir", async () => {
|
|
23
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-validate-quickstart-style-"));
|
|
24
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
25
|
+
await assert.rejects(() => validateStep({
|
|
26
|
+
rootDir,
|
|
27
|
+
checkpoint: { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "QUICK_START" },
|
|
28
|
+
step: { kind: "quickstart", phase: "style" }
|
|
29
|
+
}), /Missing required file: staging\/quickstart\/contracts/);
|
|
30
|
+
});
|
|
31
|
+
test("validateStep(quickstart:characters) rejects empty contracts dir", async () => {
|
|
32
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-validate-quickstart-empty-contracts-"));
|
|
33
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
34
|
+
await mkdir(join(rootDir, "staging/quickstart/contracts"), { recursive: true });
|
|
35
|
+
await assert.rejects(() => validateStep({
|
|
36
|
+
rootDir,
|
|
37
|
+
checkpoint: { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "QUICK_START" },
|
|
38
|
+
step: { kind: "quickstart", phase: "characters" }
|
|
39
|
+
}), /expected at least 1 \*\.json contract file/);
|
|
40
|
+
});
|
|
41
|
+
test("validateStep(quickstart:style) rejects invalid style source_type", async () => {
|
|
42
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-validate-quickstart-invalid-style-"));
|
|
43
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
44
|
+
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
45
|
+
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "banana" });
|
|
46
|
+
await assert.rejects(() => validateStep({
|
|
47
|
+
rootDir,
|
|
48
|
+
checkpoint: { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "QUICK_START" },
|
|
49
|
+
step: { kind: "quickstart", phase: "style" }
|
|
50
|
+
}), /Invalid staging\/quickstart\/style-profile\.json: source_type must be one of:/);
|
|
51
|
+
});
|
|
52
|
+
test("validateStep(quickstart:trial) requires style-profile.json", async () => {
|
|
53
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-validate-quickstart-trial-"));
|
|
54
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
55
|
+
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
56
|
+
await assert.rejects(() => validateStep({
|
|
57
|
+
rootDir,
|
|
58
|
+
checkpoint: { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "QUICK_START" },
|
|
59
|
+
step: { kind: "quickstart", phase: "trial" }
|
|
60
|
+
}), /Missing required file: staging\/quickstart\/style-profile\.json/);
|
|
61
|
+
});
|
|
62
|
+
test("validateStep(quickstart:trial) rejects empty trial chapter", async () => {
|
|
63
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-validate-quickstart-empty-trial-"));
|
|
64
|
+
await writeJson(join(rootDir, "staging/quickstart/rules.json"), { rules: [] });
|
|
65
|
+
await writeJson(join(rootDir, "staging/quickstart/contracts/hero.json"), { id: "hero", display_name: "阿宁", contracts: [] });
|
|
66
|
+
await writeJson(join(rootDir, "staging/quickstart/style-profile.json"), { source_type: "template" });
|
|
67
|
+
await writeText(join(rootDir, "staging/quickstart/trial-chapter.md"), "");
|
|
68
|
+
await assert.rejects(() => validateStep({
|
|
69
|
+
rootDir,
|
|
70
|
+
checkpoint: { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "QUICK_START" },
|
|
71
|
+
step: { kind: "quickstart", phase: "trial" }
|
|
72
|
+
}), /Empty draft file: staging\/quickstart\/trial-chapter\.md/);
|
|
73
|
+
});
|
package/dist/advance.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { copyFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
2
3
|
import { readCheckpoint, writeCheckpoint } from "./checkpoint.js";
|
|
3
4
|
import { NovelCliError } from "./errors.js";
|
|
4
|
-
import { removePath } from "./fs-utils.js";
|
|
5
|
+
import { ensureDir, pathExists, removePath } from "./fs-utils.js";
|
|
5
6
|
import { withWriteLock } from "./lock.js";
|
|
7
|
+
import { QUICKSTART_FINAL_RELS, QUICKSTART_STAGING_RELS } from "./quickstart.js";
|
|
6
8
|
import { chapterRelPaths, formatStepId, titleFixSnapshotRel } from "./steps.js";
|
|
7
9
|
import { validateStep } from "./validate.js";
|
|
8
10
|
import { VOL_REVIEW_RELS } from "./volume-review.js";
|
|
@@ -109,6 +111,88 @@ export async function advanceCheckpointForStep(args) {
|
|
|
109
111
|
return updated;
|
|
110
112
|
});
|
|
111
113
|
}
|
|
114
|
+
if (step.kind === "quickstart") {
|
|
115
|
+
const qsStep = step;
|
|
116
|
+
const copyFileSafe = async (fromRel, toRel) => {
|
|
117
|
+
const fromAbs = join(args.rootDir, fromRel);
|
|
118
|
+
const toAbs = join(args.rootDir, toRel);
|
|
119
|
+
await ensureDir(dirname(toAbs));
|
|
120
|
+
await copyFile(fromAbs, toAbs);
|
|
121
|
+
};
|
|
122
|
+
const commitQuickStartArtifacts = async () => {
|
|
123
|
+
// Core artifacts
|
|
124
|
+
const requiredRelPaths = [
|
|
125
|
+
QUICKSTART_STAGING_RELS.rulesJson,
|
|
126
|
+
QUICKSTART_STAGING_RELS.styleProfileJson,
|
|
127
|
+
QUICKSTART_STAGING_RELS.trialChapterMd,
|
|
128
|
+
QUICKSTART_STAGING_RELS.evaluationJson
|
|
129
|
+
];
|
|
130
|
+
for (const rel of requiredRelPaths) {
|
|
131
|
+
const abs = join(args.rootDir, rel);
|
|
132
|
+
if (!(await pathExists(abs)))
|
|
133
|
+
throw new NovelCliError(`Missing required file: ${rel}`, 2);
|
|
134
|
+
}
|
|
135
|
+
await copyFileSafe(QUICKSTART_STAGING_RELS.rulesJson, QUICKSTART_FINAL_RELS.worldRulesJson);
|
|
136
|
+
await copyFileSafe(QUICKSTART_STAGING_RELS.styleProfileJson, QUICKSTART_FINAL_RELS.styleProfileJson);
|
|
137
|
+
await copyFileSafe(QUICKSTART_STAGING_RELS.trialChapterMd, QUICKSTART_FINAL_RELS.trialChapterMd);
|
|
138
|
+
await copyFileSafe(QUICKSTART_STAGING_RELS.evaluationJson, QUICKSTART_FINAL_RELS.evaluationJson);
|
|
139
|
+
// Contracts dir → characters/active/*.json (overwrite by filename).
|
|
140
|
+
const contractsAbs = join(args.rootDir, QUICKSTART_STAGING_RELS.contractsDir);
|
|
141
|
+
if (!(await pathExists(contractsAbs)))
|
|
142
|
+
throw new NovelCliError(`Missing required directory: ${QUICKSTART_STAGING_RELS.contractsDir}`, 2);
|
|
143
|
+
const entries = await readdir(contractsAbs, { withFileTypes: true });
|
|
144
|
+
const jsonFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".json")).map((e) => e.name).sort();
|
|
145
|
+
if (jsonFiles.length === 0) {
|
|
146
|
+
throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.contractsDir}: expected at least 1 *.json contract file.`, 2);
|
|
147
|
+
}
|
|
148
|
+
const activeDirAbs = join(args.rootDir, QUICKSTART_FINAL_RELS.charactersActiveDir);
|
|
149
|
+
await ensureDir(activeDirAbs);
|
|
150
|
+
for (const name of jsonFiles) {
|
|
151
|
+
await copyFile(join(contractsAbs, name), join(activeDirAbs, name));
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
return await withWriteLock(args.rootDir, {}, async () => {
|
|
155
|
+
const checkpoint = await readCheckpoint(args.rootDir);
|
|
156
|
+
if (checkpoint.orchestrator_state !== "INIT" && checkpoint.orchestrator_state !== "QUICK_START") {
|
|
157
|
+
throw new NovelCliError(`Cannot advance ${formatStepId(qsStep)} unless orchestrator_state=INIT or QUICK_START.`, 2);
|
|
158
|
+
}
|
|
159
|
+
const stage = checkpoint.pipeline_stage ?? null;
|
|
160
|
+
const inflight = typeof checkpoint.inflight_chapter === "number" ? checkpoint.inflight_chapter : null;
|
|
161
|
+
if (inflight !== null) {
|
|
162
|
+
throw new NovelCliError(`Checkpoint inconsistent for QUICK_START advance: inflight_chapter=${inflight} (expected null). Finish the chapter pipeline or repair .checkpoint.json.`, 2);
|
|
163
|
+
}
|
|
164
|
+
if (stage !== null && stage !== "committed") {
|
|
165
|
+
throw new NovelCliError(`Checkpoint inconsistent for QUICK_START advance: pipeline_stage=${stage} (expected null or committed). Finish the chapter pipeline or repair .checkpoint.json.`, 2);
|
|
166
|
+
}
|
|
167
|
+
// Enforce validate-before-advance to keep deterministic semantics.
|
|
168
|
+
await validateStep({ rootDir: args.rootDir, checkpoint, step: qsStep });
|
|
169
|
+
const updated = { ...checkpoint };
|
|
170
|
+
updated.inflight_chapter = null;
|
|
171
|
+
updated.pipeline_stage = null;
|
|
172
|
+
updated.quickstart_phase = qsStep.phase;
|
|
173
|
+
if (qsStep.phase === "results") {
|
|
174
|
+
await commitQuickStartArtifacts();
|
|
175
|
+
updated.orchestrator_state = "VOL_PLANNING";
|
|
176
|
+
updated.volume_pipeline_stage = null;
|
|
177
|
+
updated.quickstart_phase = null;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
updated.orchestrator_state = "QUICK_START";
|
|
181
|
+
}
|
|
182
|
+
updated.last_checkpoint_time = new Date().toISOString();
|
|
183
|
+
await writeCheckpoint(args.rootDir, updated);
|
|
184
|
+
if (qsStep.phase === "results") {
|
|
185
|
+
// Best-effort cleanup: keep artifacts committed even if staging removal fails.
|
|
186
|
+
try {
|
|
187
|
+
await removePath(join(args.rootDir, QUICKSTART_STAGING_RELS.dir));
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// ignore
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return updated;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
112
196
|
if (step.kind === "review") {
|
|
113
197
|
const reviewStep = step;
|
|
114
198
|
return await withWriteLock(args.rootDir, {}, async () => {
|
|
@@ -168,5 +252,6 @@ export async function advanceCheckpointForStep(args) {
|
|
|
168
252
|
return updated;
|
|
169
253
|
});
|
|
170
254
|
}
|
|
171
|
-
|
|
255
|
+
// parseStepId ensures this is exhaustive.
|
|
256
|
+
throw new NovelCliError(`Unsupported step kind.`, 2);
|
|
172
257
|
}
|
package/dist/checkpoint.js
CHANGED
|
@@ -1,17 +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
|
+
import { ORCHESTRATOR_STATES, QUICKSTART_PHASES, VOLUME_PHASES } from "./steps.js";
|
|
5
5
|
import { isPlainObject } from "./type-guards.js";
|
|
6
6
|
export const PIPELINE_STAGES = ["drafting", "drafted", "refined", "judged", "revising", "committed"];
|
|
7
7
|
export function createDefaultCheckpoint(nowIso) {
|
|
8
8
|
return {
|
|
9
9
|
last_completed_chapter: 0,
|
|
10
10
|
current_volume: 1,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
pipeline_stage: "committed",
|
|
11
|
+
orchestrator_state: "INIT",
|
|
12
|
+
pipeline_stage: null,
|
|
14
13
|
volume_pipeline_stage: null,
|
|
14
|
+
quickstart_phase: null,
|
|
15
15
|
inflight_chapter: null,
|
|
16
16
|
revision_count: 0,
|
|
17
17
|
hook_fix_count: 0,
|
|
@@ -113,6 +113,25 @@ function parseCheckpoint(data) {
|
|
|
113
113
|
else {
|
|
114
114
|
throw new NovelCliError(`.checkpoint.json.volume_pipeline_stage must be a string (or null)`, 2);
|
|
115
115
|
}
|
|
116
|
+
const quickstartPhaseRaw = data.quickstart_phase;
|
|
117
|
+
let quickstartPhase;
|
|
118
|
+
if (quickstartPhaseRaw === undefined) {
|
|
119
|
+
quickstartPhase = undefined;
|
|
120
|
+
}
|
|
121
|
+
else if (quickstartPhaseRaw === null) {
|
|
122
|
+
quickstartPhase = null;
|
|
123
|
+
}
|
|
124
|
+
else if (typeof quickstartPhaseRaw === "string") {
|
|
125
|
+
if (QUICKSTART_PHASES.includes(quickstartPhaseRaw)) {
|
|
126
|
+
quickstartPhase = quickstartPhaseRaw;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
throw new NovelCliError(`.checkpoint.json.quickstart_phase must be one of: ${QUICKSTART_PHASES.join(", ")} (or null)`, 2);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
throw new NovelCliError(`.checkpoint.json.quickstart_phase must be a string (or null)`, 2);
|
|
134
|
+
}
|
|
116
135
|
const lastCommitted = data.last_committed_volume;
|
|
117
136
|
if (lastCommitted !== undefined) {
|
|
118
137
|
const lc = asInt(lastCommitted);
|
|
@@ -174,6 +193,8 @@ function parseCheckpoint(data) {
|
|
|
174
193
|
checkpoint.pipeline_stage = pipelineStage;
|
|
175
194
|
if (volumeStage !== undefined)
|
|
176
195
|
checkpoint.volume_pipeline_stage = volumeStage;
|
|
196
|
+
if (quickstartPhase !== undefined)
|
|
197
|
+
checkpoint.quickstart_phase = quickstartPhase;
|
|
177
198
|
if (inflight !== undefined)
|
|
178
199
|
checkpoint.inflight_chapter = inflight;
|
|
179
200
|
return checkpoint;
|