novel-writer-cli 0.0.3 → 0.2.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__/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__/gate-decision.test.js +66 -0
- package/dist/__tests__/init.test.js +14 -6
- package/dist/__tests__/instructions-review-novel-ask-gate.test.js +31 -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 +172 -0
- package/dist/__tests__/orchestrator-state-write-path.test.js +59 -0
- package/dist/__tests__/quickstart-pipeline.test.js +346 -0
- package/dist/__tests__/safe-path-symlink.test.js +41 -0
- package/dist/__tests__/steps-id.test.js +23 -0
- package/dist/__tests__/validate-quickstart-prereqs.test.js +73 -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 +234 -52
- package/dist/checkpoint.js +93 -13
- package/dist/cli.js +318 -11
- package/dist/commit.js +1 -0
- package/dist/fs-utils.js +18 -3
- package/dist/gate-decision.js +59 -0
- package/dist/init.js +4 -1
- package/dist/instructions.js +483 -24
- package/dist/next-step.js +421 -34
- package/dist/platform-profile.js +3 -0
- package/dist/quickstart-validators.js +84 -0
- package/dist/quickstart.js +16 -0
- package/dist/safe-path.js +23 -1
- package/dist/steps.js +60 -17
- package/dist/validate.js +347 -3
- package/dist/volume-commit.js +101 -0
- package/dist/volume-planning.js +143 -0
- package/dist/volume-review.js +448 -0
- package/docs/user/README.md +0 -1
- package/docs/user/novel-cli.md +29 -0
- package/package.json +3 -2
- package/schemas/platform-profile.schema.json +5 -0
- 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,37 @@
|
|
|
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 { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { advanceCheckpointForStep } from "../advance.js";
|
|
7
|
+
async function exists(absPath) {
|
|
8
|
+
try {
|
|
9
|
+
await stat(absPath);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
test("advanceCheckpointForStep(chapter:refine) invalidates eval and counts polish revisions after judge", async () => {
|
|
17
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-advance-refine-"));
|
|
18
|
+
await writeFile(join(rootDir, ".checkpoint.json"), `${JSON.stringify({
|
|
19
|
+
last_completed_chapter: 0,
|
|
20
|
+
current_volume: 1,
|
|
21
|
+
orchestrator_state: "WRITING",
|
|
22
|
+
pipeline_stage: "judged",
|
|
23
|
+
inflight_chapter: 1,
|
|
24
|
+
revision_count: 0
|
|
25
|
+
}, null, 2)}\n`, "utf8");
|
|
26
|
+
await mkdir(join(rootDir, "staging/chapters"), { recursive: true });
|
|
27
|
+
await writeFile(join(rootDir, "staging/chapters/chapter-001.md"), "draft text\n", "utf8");
|
|
28
|
+
await mkdir(join(rootDir, "staging/evaluations"), { recursive: true });
|
|
29
|
+
await writeFile(join(rootDir, "staging/evaluations/chapter-001-eval.json"), `{"chapter":1,"overall":3.6,"recommendation":"polish"}\n`, "utf8");
|
|
30
|
+
assert.equal(await exists(join(rootDir, "staging/evaluations/chapter-001-eval.json")), true);
|
|
31
|
+
const updated = await advanceCheckpointForStep({ rootDir, step: { kind: "chapter", chapter: 1, stage: "refine" } });
|
|
32
|
+
assert.equal(updated.pipeline_stage, "refined");
|
|
33
|
+
assert.equal(updated.inflight_chapter, 1);
|
|
34
|
+
assert.equal(updated.revision_count, 1);
|
|
35
|
+
assert.equal(updated.orchestrator_state, "CHAPTER_REWRITE");
|
|
36
|
+
assert.equal(await exists(join(rootDir, "staging/evaluations/chapter-001-eval.json")), false);
|
|
37
|
+
});
|
|
@@ -407,7 +407,7 @@ test("buildInstructionPacket injects character voice drift directives into draft
|
|
|
407
407
|
]
|
|
408
408
|
});
|
|
409
409
|
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
|
|
410
|
-
const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
|
|
410
|
+
const checkpoint = { last_completed_chapter: 10, current_volume: 1, orchestrator_state: "WRITING" };
|
|
411
411
|
const draftOut = (await buildInstructionPacket({
|
|
412
412
|
rootDir,
|
|
413
413
|
checkpoint,
|
|
@@ -0,0 +1,49 @@
|
|
|
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 { readCheckpoint } from "../checkpoint.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("readCheckpoint rejects invalid quickstart_phase string", async () => {
|
|
15
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-checkpoint-quickstart-phase-"));
|
|
16
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
17
|
+
last_completed_chapter: 0,
|
|
18
|
+
current_volume: 1,
|
|
19
|
+
orchestrator_state: "QUICK_START",
|
|
20
|
+
pipeline_stage: null,
|
|
21
|
+
inflight_chapter: null,
|
|
22
|
+
quickstart_phase: "banana"
|
|
23
|
+
});
|
|
24
|
+
await assert.rejects(() => readCheckpoint(rootDir), /quickstart_phase must be one of:/);
|
|
25
|
+
});
|
|
26
|
+
test("readCheckpoint rejects non-string quickstart_phase", async () => {
|
|
27
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-checkpoint-quickstart-phase-non-string-"));
|
|
28
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
29
|
+
last_completed_chapter: 0,
|
|
30
|
+
current_volume: 1,
|
|
31
|
+
orchestrator_state: "QUICK_START",
|
|
32
|
+
pipeline_stage: null,
|
|
33
|
+
inflight_chapter: null,
|
|
34
|
+
quickstart_phase: 42
|
|
35
|
+
});
|
|
36
|
+
await assert.rejects(() => readCheckpoint(rootDir), /quickstart_phase must be a string/);
|
|
37
|
+
});
|
|
38
|
+
test("readCheckpoint accepts legacy checkpoint without quickstart_phase", async () => {
|
|
39
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-checkpoint-quickstart-phase-legacy-missing-"));
|
|
40
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
41
|
+
last_completed_chapter: 0,
|
|
42
|
+
current_volume: 1,
|
|
43
|
+
orchestrator_state: "QUICK_START",
|
|
44
|
+
pipeline_stage: null,
|
|
45
|
+
inflight_chapter: null
|
|
46
|
+
});
|
|
47
|
+
const checkpoint = await readCheckpoint(rootDir);
|
|
48
|
+
assert.equal(checkpoint.quickstart_phase ?? null, null);
|
|
49
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
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 { main } from "../cli.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
|
+
async function runCli(argv) {
|
|
15
|
+
let stdout = "";
|
|
16
|
+
let stderr = "";
|
|
17
|
+
const origOut = process.stdout.write;
|
|
18
|
+
const origErr = process.stderr.write;
|
|
19
|
+
process.stdout.write = (chunk) => {
|
|
20
|
+
stdout += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
21
|
+
return true;
|
|
22
|
+
};
|
|
23
|
+
process.stderr.write = (chunk) => {
|
|
24
|
+
stderr += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
25
|
+
return true;
|
|
26
|
+
};
|
|
27
|
+
const prevExitCode = process.exitCode;
|
|
28
|
+
try {
|
|
29
|
+
const code = await main(argv);
|
|
30
|
+
return { code, stdout, stderr };
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
process.exitCode = prevExitCode;
|
|
34
|
+
process.stdout.write = origOut;
|
|
35
|
+
process.stderr.write = origErr;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
test("novel instructions rejects --novel-ask-file without --answer-path (json mode)", async () => {
|
|
39
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-novel-ask-missing-answer-"));
|
|
40
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
41
|
+
last_completed_chapter: 0,
|
|
42
|
+
current_volume: 1,
|
|
43
|
+
orchestrator_state: "INIT",
|
|
44
|
+
pipeline_stage: null,
|
|
45
|
+
inflight_chapter: null
|
|
46
|
+
});
|
|
47
|
+
const res = await runCli([
|
|
48
|
+
"--json",
|
|
49
|
+
"--project",
|
|
50
|
+
rootDir,
|
|
51
|
+
"instructions",
|
|
52
|
+
"quickstart:world",
|
|
53
|
+
"--novel-ask-file",
|
|
54
|
+
"staging/novel-ask/question.json"
|
|
55
|
+
]);
|
|
56
|
+
assert.equal(res.code, 2);
|
|
57
|
+
const payload = JSON.parse(res.stdout.trim());
|
|
58
|
+
assert.equal(payload.ok, false);
|
|
59
|
+
assert.match(payload.error.message, /provide both --novel-ask-file and --answer-path/);
|
|
60
|
+
});
|
|
61
|
+
test("novel instructions rejects --answer-path without --novel-ask-file (json mode)", async () => {
|
|
62
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-novel-ask-missing-ask-"));
|
|
63
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
64
|
+
last_completed_chapter: 0,
|
|
65
|
+
current_volume: 1,
|
|
66
|
+
orchestrator_state: "INIT",
|
|
67
|
+
pipeline_stage: null,
|
|
68
|
+
inflight_chapter: null
|
|
69
|
+
});
|
|
70
|
+
const res = await runCli([
|
|
71
|
+
"--json",
|
|
72
|
+
"--project",
|
|
73
|
+
rootDir,
|
|
74
|
+
"instructions",
|
|
75
|
+
"quickstart:world",
|
|
76
|
+
"--answer-path",
|
|
77
|
+
"staging/novel-ask/answer.json"
|
|
78
|
+
]);
|
|
79
|
+
assert.equal(res.code, 2);
|
|
80
|
+
const payload = JSON.parse(res.stdout.trim());
|
|
81
|
+
assert.equal(payload.ok, false);
|
|
82
|
+
assert.match(payload.error.message, /provide both --novel-ask-file and --answer-path/);
|
|
83
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, 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 { main } from "../cli.js";
|
|
7
|
+
import { readCheckpoint } from "../checkpoint.js";
|
|
8
|
+
async function writeText(absPath, contents) {
|
|
9
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
10
|
+
await writeFile(absPath, contents, "utf8");
|
|
11
|
+
}
|
|
12
|
+
async function writeJson(absPath, payload) {
|
|
13
|
+
await writeText(absPath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
14
|
+
}
|
|
15
|
+
async function runCli(argv) {
|
|
16
|
+
let stdout = "";
|
|
17
|
+
let stderr = "";
|
|
18
|
+
const origOut = process.stdout.write;
|
|
19
|
+
const origErr = process.stderr.write;
|
|
20
|
+
process.stdout.write = (chunk) => {
|
|
21
|
+
stdout += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
22
|
+
return true;
|
|
23
|
+
};
|
|
24
|
+
process.stderr.write = (chunk) => {
|
|
25
|
+
stderr += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
26
|
+
return true;
|
|
27
|
+
};
|
|
28
|
+
const prevExitCode = process.exitCode;
|
|
29
|
+
try {
|
|
30
|
+
process.exitCode = undefined;
|
|
31
|
+
const code = await main(argv);
|
|
32
|
+
return { code, stdout, stderr };
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
process.exitCode = prevExitCode;
|
|
36
|
+
process.stdout.write = origOut;
|
|
37
|
+
process.stderr.write = origErr;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
test("novel repair previews reset-quickstart without --force (json mode)", async () => {
|
|
41
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-repair-quickstart-preview-"));
|
|
42
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
43
|
+
last_completed_chapter: 0,
|
|
44
|
+
current_volume: 1,
|
|
45
|
+
orchestrator_state: "QUICK_START",
|
|
46
|
+
pipeline_stage: null,
|
|
47
|
+
inflight_chapter: null,
|
|
48
|
+
quickstart_phase: "characters"
|
|
49
|
+
});
|
|
50
|
+
const res = await runCli(["--json", "--project", rootDir, "repair", "--reset-quickstart"]);
|
|
51
|
+
assert.equal(res.code, 0);
|
|
52
|
+
assert.equal(res.stderr, "");
|
|
53
|
+
const payload = JSON.parse(res.stdout.trim());
|
|
54
|
+
assert.equal(payload.ok, true);
|
|
55
|
+
assert.equal(payload.command, "repair");
|
|
56
|
+
assert.equal(payload.data.rootDir, rootDir);
|
|
57
|
+
assert.deepEqual(payload.data.actions, ["reset_quickstart"]);
|
|
58
|
+
assert.equal(payload.data.applied, false);
|
|
59
|
+
assert.equal(payload.data.before_present, true);
|
|
60
|
+
assert.equal(payload.data.after_present, true);
|
|
61
|
+
assert.equal(payload.data.changed, false);
|
|
62
|
+
assert.equal(payload.data.would_change, true);
|
|
63
|
+
assert.equal(payload.data.before, "characters");
|
|
64
|
+
assert.equal(payload.data.after, "characters");
|
|
65
|
+
assert.equal(payload.data.target_after, null);
|
|
66
|
+
const checkpoint = await readCheckpoint(rootDir);
|
|
67
|
+
assert.equal(checkpoint.quickstart_phase, "characters");
|
|
68
|
+
});
|
|
69
|
+
test("novel repair --reset-quickstart --force sets quickstart_phase=null (json mode)", async () => {
|
|
70
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-repair-quickstart-force-"));
|
|
71
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
72
|
+
last_completed_chapter: 0,
|
|
73
|
+
current_volume: 1,
|
|
74
|
+
orchestrator_state: "QUICK_START",
|
|
75
|
+
pipeline_stage: null,
|
|
76
|
+
inflight_chapter: null,
|
|
77
|
+
quickstart_phase: "characters"
|
|
78
|
+
});
|
|
79
|
+
const res = await runCli(["--json", "--project", rootDir, "repair", "--reset-quickstart", "--force"]);
|
|
80
|
+
assert.equal(res.code, 0);
|
|
81
|
+
assert.equal(res.stderr, "");
|
|
82
|
+
const payload = JSON.parse(res.stdout.trim());
|
|
83
|
+
assert.equal(payload.ok, true);
|
|
84
|
+
assert.equal(payload.command, "repair");
|
|
85
|
+
assert.equal(payload.data.rootDir, rootDir);
|
|
86
|
+
assert.deepEqual(payload.data.actions, ["reset_quickstart"]);
|
|
87
|
+
assert.equal(payload.data.applied, true);
|
|
88
|
+
assert.equal(payload.data.before_present, true);
|
|
89
|
+
assert.equal(payload.data.after_present, true);
|
|
90
|
+
assert.equal(payload.data.changed, true);
|
|
91
|
+
assert.equal(payload.data.would_change, true);
|
|
92
|
+
assert.equal(payload.data.before, "characters");
|
|
93
|
+
assert.equal(payload.data.after, null);
|
|
94
|
+
assert.equal(payload.data.target_after, null);
|
|
95
|
+
const checkpoint = await readCheckpoint(rootDir);
|
|
96
|
+
assert.equal(checkpoint.quickstart_phase, null);
|
|
97
|
+
const raw = await readFile(join(rootDir, ".checkpoint.json"), "utf8");
|
|
98
|
+
assert.match(raw, /\"quickstart_phase\": null/);
|
|
99
|
+
await assert.rejects(() => stat(join(rootDir, ".novel.lock")), /ENOENT/);
|
|
100
|
+
});
|
|
101
|
+
test("novel repair --reset-quickstart --force normalizes missing quickstart_phase to null (json mode)", async () => {
|
|
102
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-repair-quickstart-missing-"));
|
|
103
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
104
|
+
last_completed_chapter: 0,
|
|
105
|
+
current_volume: 1,
|
|
106
|
+
orchestrator_state: "QUICK_START",
|
|
107
|
+
pipeline_stage: null,
|
|
108
|
+
inflight_chapter: null
|
|
109
|
+
});
|
|
110
|
+
const res = await runCli(["--json", "--project", rootDir, "repair", "--reset-quickstart", "--force"]);
|
|
111
|
+
assert.equal(res.code, 0);
|
|
112
|
+
assert.equal(res.stderr, "");
|
|
113
|
+
const payload = JSON.parse(res.stdout.trim());
|
|
114
|
+
assert.equal(payload.ok, true);
|
|
115
|
+
assert.equal(payload.command, "repair");
|
|
116
|
+
assert.equal(payload.data.applied, true);
|
|
117
|
+
assert.equal(payload.data.before_present, false);
|
|
118
|
+
assert.equal(payload.data.after_present, true);
|
|
119
|
+
assert.equal(payload.data.changed, true);
|
|
120
|
+
assert.equal(payload.data.would_change, true);
|
|
121
|
+
assert.equal(payload.data.before, null);
|
|
122
|
+
assert.equal(payload.data.after, null);
|
|
123
|
+
const checkpoint = await readCheckpoint(rootDir);
|
|
124
|
+
assert.equal(checkpoint.quickstart_phase, null);
|
|
125
|
+
});
|
|
126
|
+
test("novel repair --reset-quickstart --force is idempotent when already null (json mode)", async () => {
|
|
127
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-repair-quickstart-noop-"));
|
|
128
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
129
|
+
last_completed_chapter: 0,
|
|
130
|
+
current_volume: 1,
|
|
131
|
+
orchestrator_state: "QUICK_START",
|
|
132
|
+
pipeline_stage: null,
|
|
133
|
+
inflight_chapter: null,
|
|
134
|
+
quickstart_phase: null
|
|
135
|
+
});
|
|
136
|
+
const res = await runCli(["--json", "--project", rootDir, "repair", "--reset-quickstart", "--force"]);
|
|
137
|
+
assert.equal(res.code, 0);
|
|
138
|
+
const payload = JSON.parse(res.stdout.trim());
|
|
139
|
+
assert.equal(payload.ok, true);
|
|
140
|
+
assert.equal(payload.data.applied, true);
|
|
141
|
+
assert.equal(payload.data.changed, false);
|
|
142
|
+
assert.equal(payload.data.would_change, false);
|
|
143
|
+
assert.equal(payload.data.before, null);
|
|
144
|
+
assert.equal(payload.data.after, null);
|
|
145
|
+
});
|
|
146
|
+
test("novel repair --reset-quickstart fails gracefully when checkpoint missing (json mode)", async () => {
|
|
147
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-repair-quickstart-no-checkpoint-"));
|
|
148
|
+
const res = await runCli(["--json", "--project", rootDir, "repair", "--reset-quickstart", "--force"]);
|
|
149
|
+
assert.equal(res.code, 2);
|
|
150
|
+
const payload = JSON.parse(res.stdout.trim());
|
|
151
|
+
assert.equal(payload.ok, false);
|
|
152
|
+
assert.equal(payload.command, "repair");
|
|
153
|
+
});
|
|
154
|
+
test("novel repair --reset-quickstart fails gracefully when checkpoint is corrupt JSON (json mode)", async () => {
|
|
155
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-repair-quickstart-corrupt-"));
|
|
156
|
+
await writeText(join(rootDir, ".checkpoint.json"), "{ broken json !!!}");
|
|
157
|
+
const res = await runCli(["--json", "--project", rootDir, "repair", "--reset-quickstart", "--force"]);
|
|
158
|
+
assert.ok(res.code !== 0, `expected non-zero exit code, got ${res.code}`);
|
|
159
|
+
const payload = JSON.parse(res.stdout.trim());
|
|
160
|
+
assert.equal(payload.ok, false);
|
|
161
|
+
assert.equal(payload.command, "repair");
|
|
162
|
+
});
|
|
163
|
+
test("novel repair preview reports after_present=true even when field is missing (json mode)", async () => {
|
|
164
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-repair-quickstart-preview-missing-"));
|
|
165
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
166
|
+
last_completed_chapter: 0,
|
|
167
|
+
current_volume: 1,
|
|
168
|
+
orchestrator_state: "QUICK_START",
|
|
169
|
+
pipeline_stage: null,
|
|
170
|
+
inflight_chapter: null
|
|
171
|
+
});
|
|
172
|
+
const res = await runCli(["--json", "--project", rootDir, "repair", "--reset-quickstart"]);
|
|
173
|
+
assert.equal(res.code, 0);
|
|
174
|
+
const payload = JSON.parse(res.stdout.trim());
|
|
175
|
+
assert.equal(payload.data.before_present, false);
|
|
176
|
+
assert.equal(payload.data.after_present, true);
|
|
177
|
+
assert.equal(payload.data.would_change, true);
|
|
178
|
+
});
|
|
179
|
+
test("novel repair rejects missing actions (json mode)", async () => {
|
|
180
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-cli-repair-missing-action-"));
|
|
181
|
+
await writeJson(join(rootDir, ".checkpoint.json"), {
|
|
182
|
+
last_completed_chapter: 0,
|
|
183
|
+
current_volume: 1,
|
|
184
|
+
orchestrator_state: "INIT",
|
|
185
|
+
pipeline_stage: null,
|
|
186
|
+
inflight_chapter: null
|
|
187
|
+
});
|
|
188
|
+
const res = await runCli(["--json", "--project", rootDir, "repair"]);
|
|
189
|
+
assert.equal(res.code, 2);
|
|
190
|
+
const payload = JSON.parse(res.stdout.trim());
|
|
191
|
+
assert.equal(payload.ok, false);
|
|
192
|
+
assert.equal(payload.command, "repair");
|
|
193
|
+
assert.match(payload.error.message, /no actions specified/i);
|
|
194
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { computeGateDecision, detectHighConfidenceViolation } from "../gate-decision.js";
|
|
4
|
+
test("computeGateDecision maps score bands to decisions (no violations)", () => {
|
|
5
|
+
assert.equal(computeGateDecision({ overall_final: 4.0, revision_count: 0, has_high_confidence_violation: false }), "pass");
|
|
6
|
+
assert.equal(computeGateDecision({ overall_final: 3.9, revision_count: 0, has_high_confidence_violation: false }), "polish");
|
|
7
|
+
assert.equal(computeGateDecision({ overall_final: 3.5, revision_count: 0, has_high_confidence_violation: false }), "polish");
|
|
8
|
+
assert.equal(computeGateDecision({ overall_final: 3.4, revision_count: 0, has_high_confidence_violation: false }), "revise");
|
|
9
|
+
assert.equal(computeGateDecision({ overall_final: 3.0, revision_count: 0, has_high_confidence_violation: false }), "revise");
|
|
10
|
+
assert.equal(computeGateDecision({ overall_final: 2.9, revision_count: 0, has_high_confidence_violation: false }), "pause_for_user");
|
|
11
|
+
assert.equal(computeGateDecision({ overall_final: 2.0, revision_count: 0, has_high_confidence_violation: false }), "pause_for_user");
|
|
12
|
+
assert.equal(computeGateDecision({ overall_final: 1.99, revision_count: 0, has_high_confidence_violation: false }), "pause_for_user_force_rewrite");
|
|
13
|
+
});
|
|
14
|
+
test("computeGateDecision forces revise on high-confidence violations", () => {
|
|
15
|
+
assert.equal(computeGateDecision({ overall_final: 4.8, revision_count: 0, has_high_confidence_violation: true }), "revise");
|
|
16
|
+
});
|
|
17
|
+
test("computeGateDecision pauses for user when high-confidence violations persist beyond max_revisions", () => {
|
|
18
|
+
assert.equal(computeGateDecision({ overall_final: 4.8, revision_count: 2, has_high_confidence_violation: true }), "pause_for_user");
|
|
19
|
+
});
|
|
20
|
+
test("computeGateDecision allows force_passed when revisions exhausted and score >= 3.0", () => {
|
|
21
|
+
assert.equal(computeGateDecision({ overall_final: 3.2, revision_count: 2, has_high_confidence_violation: false }), "force_passed");
|
|
22
|
+
});
|
|
23
|
+
test("computeGateDecision force-passes polish band when revisions exhausted", () => {
|
|
24
|
+
assert.equal(computeGateDecision({ overall_final: 3.6, revision_count: 2, has_high_confidence_violation: false }), "force_passed");
|
|
25
|
+
});
|
|
26
|
+
test("computeGateDecision respects max_revisions override", () => {
|
|
27
|
+
assert.equal(computeGateDecision({ overall_final: 3.6, revision_count: 1, has_high_confidence_violation: false, max_revisions: 1 }), "force_passed");
|
|
28
|
+
});
|
|
29
|
+
test("computeGateDecision supports manual force_pass override", () => {
|
|
30
|
+
assert.equal(computeGateDecision({ overall_final: 1.0, revision_count: 0, has_high_confidence_violation: true, force_pass: true }), "force_passed");
|
|
31
|
+
});
|
|
32
|
+
test("detectHighConfidenceViolation returns false when contract_verification is missing", () => {
|
|
33
|
+
assert.deepEqual(detectHighConfidenceViolation({ overall: 4.0, recommendation: "pass" }), {
|
|
34
|
+
has_high_confidence_violation: false,
|
|
35
|
+
high_confidence_violations: []
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
test("detectHighConfidenceViolation detects l1/l2/l3 high-confidence violations", () => {
|
|
39
|
+
const res = detectHighConfidenceViolation({
|
|
40
|
+
contract_verification: {
|
|
41
|
+
l1_checks: [{ status: "violation", confidence: "high", rule: "L1-001" }],
|
|
42
|
+
l2_checks: [],
|
|
43
|
+
l3_checks: []
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
assert.equal(res.has_high_confidence_violation, true);
|
|
47
|
+
assert.equal(res.high_confidence_violations.length, 1);
|
|
48
|
+
});
|
|
49
|
+
test("detectHighConfidenceViolation ignores ls_checks soft violations", () => {
|
|
50
|
+
const res = detectHighConfidenceViolation({
|
|
51
|
+
contract_verification: {
|
|
52
|
+
ls_checks: [{ status: "violation", confidence: "high", constraint_type: "soft" }]
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
assert.equal(res.has_high_confidence_violation, false);
|
|
56
|
+
});
|
|
57
|
+
test("detectHighConfidenceViolation marks inferred constraint_type for ls_checks when missing", () => {
|
|
58
|
+
const res = detectHighConfidenceViolation({
|
|
59
|
+
contract_verification: {
|
|
60
|
+
ls_checks: [{ status: "violation", confidence: "high" }]
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
assert.equal(res.has_high_confidence_violation, true);
|
|
64
|
+
assert.equal(res.high_confidence_violations.length, 1);
|
|
65
|
+
assert.equal(res.high_confidence_violations[0].constraint_type_inferred, true);
|
|
66
|
+
});
|
|
@@ -58,23 +58,28 @@ test("initProject creates a runnable skeleton with all checkpoint fields", async
|
|
|
58
58
|
assert.equal(result.rootDir, rootDir);
|
|
59
59
|
// Exact created set (non-minimal = checkpoint + 4 templates)
|
|
60
60
|
assert.deepEqual(result.created.sort(), [".checkpoint.json", "ai-blacklist.json", "brief.md", "style-profile.json", "web-novel-cliche-lint.json"].sort());
|
|
61
|
-
// All
|
|
62
|
-
assert.equal(result.ensuredDirs.length,
|
|
61
|
+
// All staging dirs ensured
|
|
62
|
+
assert.equal(result.ensuredDirs.length, 10);
|
|
63
63
|
assert.ok(result.ensuredDirs.includes("staging/chapters"));
|
|
64
64
|
assert.ok(result.ensuredDirs.includes("staging/manifests"));
|
|
65
|
+
assert.ok(result.ensuredDirs.includes("staging/volumes"));
|
|
66
|
+
assert.ok(result.ensuredDirs.includes("staging/foreshadowing"));
|
|
67
|
+
assert.ok(result.ensuredDirs.includes("staging/quickstart"));
|
|
65
68
|
// Verify ALL checkpoint fields
|
|
66
69
|
const checkpoint = await readCheckpoint(rootDir);
|
|
67
70
|
assert.equal(checkpoint.last_completed_chapter, 0);
|
|
68
71
|
assert.equal(checkpoint.current_volume, 1);
|
|
69
|
-
assert.equal(checkpoint.
|
|
72
|
+
assert.equal(checkpoint.orchestrator_state, "INIT");
|
|
73
|
+
assert.equal(checkpoint.pipeline_stage, null);
|
|
74
|
+
assert.equal(checkpoint.volume_pipeline_stage, null);
|
|
70
75
|
assert.equal(checkpoint.inflight_chapter, null);
|
|
71
76
|
assert.equal(checkpoint.revision_count, 0);
|
|
72
77
|
assert.equal(checkpoint.hook_fix_count, 0);
|
|
73
78
|
assert.equal(checkpoint.title_fix_count, 0);
|
|
74
79
|
assert.ok(typeof checkpoint.last_checkpoint_time === "string" && checkpoint.last_checkpoint_time.length > 0);
|
|
75
|
-
// Integration: next step should be
|
|
80
|
+
// Integration: next step should be quickstart:world
|
|
76
81
|
const next = await computeNextStep(rootDir, checkpoint);
|
|
77
|
-
assert.equal(next.step, "
|
|
82
|
+
assert.equal(next.step, "quickstart:world");
|
|
78
83
|
// All staging dirs exist
|
|
79
84
|
for (const relDir of [
|
|
80
85
|
"staging/chapters",
|
|
@@ -83,7 +88,10 @@ test("initProject creates a runnable skeleton with all checkpoint fields", async
|
|
|
83
88
|
"staging/evaluations",
|
|
84
89
|
"staging/logs",
|
|
85
90
|
"staging/storylines",
|
|
86
|
-
"staging/
|
|
91
|
+
"staging/volumes",
|
|
92
|
+
"staging/foreshadowing",
|
|
93
|
+
"staging/manifests",
|
|
94
|
+
"staging/quickstart"
|
|
87
95
|
]) {
|
|
88
96
|
await assertDir(join(rootDir, relDir));
|
|
89
97
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { buildInstructionPacket } from "../instructions.js";
|
|
7
|
+
test("buildInstructionPacket rejects NOVEL_ASK gate for review steps", async () => {
|
|
8
|
+
const rootDir = await mkdtemp(join(tmpdir(), "novel-review-novel-ask-"));
|
|
9
|
+
const questionSpec = {
|
|
10
|
+
version: 1,
|
|
11
|
+
topic: "review_gate",
|
|
12
|
+
questions: [
|
|
13
|
+
{
|
|
14
|
+
id: "ok_to_continue",
|
|
15
|
+
header: "Continue?",
|
|
16
|
+
question: "Continue the review pipeline?",
|
|
17
|
+
kind: "single_choice",
|
|
18
|
+
required: true,
|
|
19
|
+
options: [{ label: "yes", description: "Proceed" }]
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
};
|
|
23
|
+
await assert.rejects(async () => buildInstructionPacket({
|
|
24
|
+
rootDir,
|
|
25
|
+
checkpoint: { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "VOL_REVIEW" },
|
|
26
|
+
step: { kind: "review", phase: "collect" },
|
|
27
|
+
embedMode: null,
|
|
28
|
+
writeManifest: false,
|
|
29
|
+
novelAskGate: { novel_ask: questionSpec, answer_path: "staging/novel-ask/review.json" }
|
|
30
|
+
}), /NOVEL_ASK gate is not supported for review steps/);
|
|
31
|
+
});
|
|
@@ -74,7 +74,7 @@ test("buildInstructionPacket injects compact narrative health summaries into dra
|
|
|
74
74
|
has_blocking_issues: false
|
|
75
75
|
});
|
|
76
76
|
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
|
|
77
|
-
const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
|
|
77
|
+
const checkpoint = { last_completed_chapter: 10, current_volume: 1, orchestrator_state: "WRITING" };
|
|
78
78
|
const draftOut = (await buildInstructionPacket({
|
|
79
79
|
rootDir,
|
|
80
80
|
checkpoint,
|
|
@@ -126,7 +126,7 @@ test("buildInstructionPacket marks degraded when latest reports exist but are in
|
|
|
126
126
|
has_blocking_issues: false
|
|
127
127
|
});
|
|
128
128
|
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
|
|
129
|
-
const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
|
|
129
|
+
const checkpoint = { last_completed_chapter: 10, current_volume: 1, orchestrator_state: "WRITING" };
|
|
130
130
|
const out = (await buildInstructionPacket({
|
|
131
131
|
rootDir,
|
|
132
132
|
checkpoint,
|
|
@@ -143,7 +143,7 @@ test("buildInstructionPacket marks degraded when latest reports exist but are in
|
|
|
143
143
|
test("buildInstructionPacket does not inject narrative health when logs are missing (no summary, no degraded)", async () => {
|
|
144
144
|
const rootDir = await mkdtemp(join(tmpdir(), "novel-narrative-health-no-logs-"));
|
|
145
145
|
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
|
|
146
|
-
const checkpoint = { last_completed_chapter: 0, current_volume: 1 };
|
|
146
|
+
const checkpoint = { last_completed_chapter: 0, current_volume: 1, orchestrator_state: "WRITING" };
|
|
147
147
|
const out = (await buildInstructionPacket({
|
|
148
148
|
rootDir,
|
|
149
149
|
checkpoint,
|
|
@@ -193,7 +193,7 @@ test("buildInstructionPacket does not inject narrative health summaries for stag
|
|
|
193
193
|
has_blocking_issues: false
|
|
194
194
|
});
|
|
195
195
|
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
|
|
196
|
-
const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
|
|
196
|
+
const checkpoint = { last_completed_chapter: 10, current_volume: 1, orchestrator_state: "WRITING" };
|
|
197
197
|
const summarizeOut = (await buildInstructionPacket({
|
|
198
198
|
rootDir,
|
|
199
199
|
checkpoint,
|
|
@@ -239,7 +239,7 @@ test("buildInstructionPacket marks degraded on schema_version mismatch when late
|
|
|
239
239
|
has_blocking_issues: false
|
|
240
240
|
});
|
|
241
241
|
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
|
|
242
|
-
const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
|
|
242
|
+
const checkpoint = { last_completed_chapter: 10, current_volume: 1, orchestrator_state: "WRITING" };
|
|
243
243
|
const out = (await buildInstructionPacket({
|
|
244
244
|
rootDir,
|
|
245
245
|
checkpoint,
|
|
@@ -279,7 +279,7 @@ test("buildInstructionPacket marks promise ledger degraded on schema_version mis
|
|
|
279
279
|
issues: []
|
|
280
280
|
});
|
|
281
281
|
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), `# 第1章\n\n(占位)\n`);
|
|
282
|
-
const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
|
|
282
|
+
const checkpoint = { last_completed_chapter: 10, current_volume: 1, orchestrator_state: "WRITING" };
|
|
283
283
|
const out = (await buildInstructionPacket({
|
|
284
284
|
rootDir,
|
|
285
285
|
checkpoint,
|
|
@@ -298,7 +298,7 @@ test("buildInstructionPacket marks both degraded when both latest files are inva
|
|
|
298
298
|
await writeText(join(rootDir, "logs/engagement/latest.json"), "not-json");
|
|
299
299
|
await writeText(join(rootDir, "logs/promises/latest.json"), "not-json");
|
|
300
300
|
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), "# 第1章\n\n(占位)\n");
|
|
301
|
-
const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
|
|
301
|
+
const checkpoint = { last_completed_chapter: 10, current_volume: 1, orchestrator_state: "WRITING" };
|
|
302
302
|
const out = (await buildInstructionPacket({
|
|
303
303
|
rootDir,
|
|
304
304
|
checkpoint,
|
|
@@ -341,7 +341,7 @@ test("buildInstructionPacket treats oversized latest.json as degraded", async ()
|
|
|
341
341
|
has_blocking_issues: false
|
|
342
342
|
});
|
|
343
343
|
await writeText(join(rootDir, "staging/chapters/chapter-001.md"), "# 第1章\n\n(占位)\n");
|
|
344
|
-
const checkpoint = { last_completed_chapter: 10, current_volume: 1 };
|
|
344
|
+
const checkpoint = { last_completed_chapter: 10, current_volume: 1, orchestrator_state: "WRITING" };
|
|
345
345
|
const out = (await buildInstructionPacket({
|
|
346
346
|
rootDir,
|
|
347
347
|
checkpoint,
|