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.
Files changed (48) hide show
  1. package/dist/__tests__/advance-refine-invalidates-eval.test.js +37 -0
  2. package/dist/__tests__/character-voice.test.js +1 -1
  3. package/dist/__tests__/checkpoint-quickstart-phase.test.js +49 -0
  4. package/dist/__tests__/cli-instructions-novel-ask-gate.test.js +83 -0
  5. package/dist/__tests__/cli-repair-reset-quickstart.test.js +194 -0
  6. package/dist/__tests__/gate-decision.test.js +66 -0
  7. package/dist/__tests__/init.test.js +14 -6
  8. package/dist/__tests__/instructions-review-novel-ask-gate.test.js +31 -0
  9. package/dist/__tests__/narrative-health-injection.test.js +8 -8
  10. package/dist/__tests__/next-step-gate-decision-routing.test.js +117 -0
  11. package/dist/__tests__/next-step-prejudge-guardrails.test.js +112 -16
  12. package/dist/__tests__/next-step-title-fix.test.js +64 -8
  13. package/dist/__tests__/orchestrator-state-routing.test.js +172 -0
  14. package/dist/__tests__/orchestrator-state-write-path.test.js +59 -0
  15. package/dist/__tests__/quickstart-pipeline.test.js +346 -0
  16. package/dist/__tests__/safe-path-symlink.test.js +41 -0
  17. package/dist/__tests__/steps-id.test.js +23 -0
  18. package/dist/__tests__/validate-quickstart-prereqs.test.js +73 -0
  19. package/dist/__tests__/volume-pipeline.test.js +227 -0
  20. package/dist/__tests__/volume-review-pipeline.test.js +112 -0
  21. package/dist/__tests__/volume-review-storyline-rhythm.test.js +19 -0
  22. package/dist/advance.js +234 -52
  23. package/dist/checkpoint.js +93 -13
  24. package/dist/cli.js +318 -11
  25. package/dist/commit.js +1 -0
  26. package/dist/fs-utils.js +18 -3
  27. package/dist/gate-decision.js +59 -0
  28. package/dist/init.js +4 -1
  29. package/dist/instructions.js +483 -24
  30. package/dist/next-step.js +421 -34
  31. package/dist/platform-profile.js +3 -0
  32. package/dist/quickstart-validators.js +84 -0
  33. package/dist/quickstart.js +16 -0
  34. package/dist/safe-path.js +23 -1
  35. package/dist/steps.js +60 -17
  36. package/dist/validate.js +347 -3
  37. package/dist/volume-commit.js +101 -0
  38. package/dist/volume-planning.js +143 -0
  39. package/dist/volume-review.js +448 -0
  40. package/docs/user/README.md +0 -1
  41. package/docs/user/novel-cli.md +29 -0
  42. package/package.json +3 -2
  43. package/schemas/platform-profile.schema.json +5 -0
  44. package/scripts/sync-final-spec-skills.mjs +65 -0
  45. package/skills/cli-step/SKILL.md +186 -32
  46. package/skills/continue/SKILL.md +30 -326
  47. package/skills/shared/thin-adapter-loop.md +67 -0
  48. 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 7 staging dirs ensured
62
- assert.equal(result.ensuredDirs.length, 7);
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.pipeline_stage, "committed");
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 chapter:001:draft
80
+ // Integration: next step should be quickstart:world
76
81
  const next = await computeNextStep(rootDir, checkpoint);
77
- assert.equal(next.step, "chapter:001:draft");
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/manifests"
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,