novel-writer-cli 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +9 -1
  2. package/dist/__tests__/advance-refine-invalidates-eval.test.js +37 -0
  3. package/dist/__tests__/character-voice.test.js +1 -1
  4. package/dist/__tests__/gate-decision.test.js +66 -0
  5. package/dist/__tests__/init.test.js +245 -0
  6. package/dist/__tests__/narrative-health-injection.test.js +8 -8
  7. package/dist/__tests__/next-step-gate-decision-routing.test.js +117 -0
  8. package/dist/__tests__/next-step-prejudge-guardrails.test.js +112 -16
  9. package/dist/__tests__/next-step-title-fix.test.js +64 -8
  10. package/dist/__tests__/orchestrator-state-routing.test.js +168 -0
  11. package/dist/__tests__/orchestrator-state-write-path.test.js +59 -0
  12. package/dist/__tests__/steps-id.test.js +23 -0
  13. package/dist/__tests__/volume-pipeline.test.js +227 -0
  14. package/dist/__tests__/volume-review-pipeline.test.js +112 -0
  15. package/dist/__tests__/volume-review-storyline-rhythm.test.js +19 -0
  16. package/dist/advance.js +145 -48
  17. package/dist/checkpoint.js +83 -12
  18. package/dist/cli.js +235 -8
  19. package/dist/commit.js +1 -0
  20. package/dist/fs-utils.js +18 -3
  21. package/dist/gate-decision.js +59 -0
  22. package/dist/init.js +165 -0
  23. package/dist/instructions.js +322 -24
  24. package/dist/next-step.js +198 -34
  25. package/dist/platform-profile.js +3 -0
  26. package/dist/steps.js +60 -17
  27. package/dist/validate.js +275 -2
  28. package/dist/volume-commit.js +101 -0
  29. package/dist/volume-planning.js +143 -0
  30. package/dist/volume-review.js +448 -0
  31. package/docs/user/novel-cli.md +57 -0
  32. package/package.json +3 -2
  33. package/schemas/platform-profile.schema.json +5 -0
package/dist/init.js ADDED
@@ -0,0 +1,165 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { createDefaultCheckpoint } from "./checkpoint.js";
5
+ import { NovelCliError } from "./errors.js";
6
+ import { ensureDir, pathExists, readJsonFile, readTextFile, writeJsonFile, writeTextFile } from "./fs-utils.js";
7
+ import { rejectPathTraversalInput } from "./safe-path.js";
8
+ import { isPlainObject } from "./type-guards.js";
9
+ export function resolveInitRootDir(args) {
10
+ const cwdAbs = resolve(args.cwd);
11
+ if (!args.projectOverride)
12
+ return cwdAbs;
13
+ rejectPathTraversalInput(args.projectOverride, "--project");
14
+ return resolve(cwdAbs, args.projectOverride);
15
+ }
16
+ export function normalizePlatformId(value) {
17
+ if (value === "qidian" || value === "tomato")
18
+ return value;
19
+ throw new NovelCliError(`Invalid --platform: ${String(value)} (expected qidian|tomato).`, 2);
20
+ }
21
+ function moduleRootDir() {
22
+ // src/init.ts → <repo_root>; dist/init.js → <package_root>
23
+ // NOTE: Not compatible with single-file bundlers (esbuild/rollup).
24
+ return resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
25
+ }
26
+ const TEMPLATE_DIR = join(moduleRootDir(), "templates");
27
+ async function ensureRootIsDirectory(absPath) {
28
+ try {
29
+ const s = await stat(absPath);
30
+ if (!s.isDirectory()) {
31
+ throw new NovelCliError(`Project root is not a directory: ${absPath}`, 2);
32
+ }
33
+ }
34
+ catch (err) {
35
+ if (err instanceof NovelCliError)
36
+ throw err;
37
+ // Path does not exist or is inaccessible — attempt to create it.
38
+ await ensureDir(absPath);
39
+ }
40
+ }
41
+ async function writeIfMissingOrForce(args) {
42
+ const abs = join(args.rootDir, args.relPath);
43
+ const exists = await pathExists(abs);
44
+ if (exists && !args.force) {
45
+ args.result.skipped.push(args.relPath);
46
+ return;
47
+ }
48
+ if (args.contents.kind === "text")
49
+ await writeTextFile(abs, args.contents.text);
50
+ else
51
+ await writeJsonFile(abs, args.contents.json);
52
+ if (exists)
53
+ args.result.overwritten.push(args.relPath);
54
+ else
55
+ args.result.created.push(args.relPath);
56
+ }
57
+ async function loadTemplateText(name) {
58
+ try {
59
+ return await readTextFile(join(TEMPLATE_DIR, name));
60
+ }
61
+ catch (err) {
62
+ if (err instanceof NovelCliError) {
63
+ throw new NovelCliError(`Built-in template missing or unreadable: templates/${name}. ${err.message}`, 2);
64
+ }
65
+ throw err;
66
+ }
67
+ }
68
+ async function loadTemplateJson(name) {
69
+ let raw;
70
+ try {
71
+ raw = await readJsonFile(join(TEMPLATE_DIR, name));
72
+ }
73
+ catch (err) {
74
+ if (err instanceof NovelCliError) {
75
+ throw new NovelCliError(`Built-in template missing or unreadable: templates/${name}. ${err.message}`, 2);
76
+ }
77
+ throw err;
78
+ }
79
+ if (!isPlainObject(raw)) {
80
+ throw new NovelCliError(`Built-in template templates/${name}: expected a JSON object, got ${typeof raw}.`, 2);
81
+ }
82
+ return raw;
83
+ }
84
+ async function loadPlatformProfileTemplate(platform) {
85
+ const raw = await loadTemplateJson("platform-profile.json");
86
+ const defaults = raw.defaults;
87
+ if (!isPlainObject(defaults)) {
88
+ throw new NovelCliError("Invalid templates/platform-profile.json: missing 'defaults' object.", 2);
89
+ }
90
+ const selected = defaults[platform];
91
+ if (!isPlainObject(selected)) {
92
+ throw new NovelCliError(`Invalid templates/platform-profile.json: missing defaults.${platform} object.`, 2);
93
+ }
94
+ return selected;
95
+ }
96
+ const DEFAULT_TEMPLATES = [
97
+ { relPath: "brief.md", templateName: "brief-template.md", kind: "text" },
98
+ { relPath: "style-profile.json", templateName: "style-profile-template.json", kind: "json" },
99
+ { relPath: "ai-blacklist.json", templateName: "ai-blacklist.json", kind: "json" },
100
+ { relPath: "web-novel-cliche-lint.json", templateName: "web-novel-cliche-lint.json", kind: "json" }
101
+ ];
102
+ const STAGING_SUBDIRS = [
103
+ "staging/chapters",
104
+ "staging/summaries",
105
+ "staging/state",
106
+ "staging/evaluations",
107
+ "staging/logs",
108
+ "staging/storylines",
109
+ "staging/volumes",
110
+ "staging/foreshadowing",
111
+ "staging/manifests"
112
+ ];
113
+ export async function initProject(args) {
114
+ const force = Boolean(args.force);
115
+ const minimal = Boolean(args.minimal);
116
+ const platform = args.platform ?? null;
117
+ const result = {
118
+ rootDir: args.rootDir,
119
+ ensuredDirs: [],
120
+ created: [],
121
+ overwritten: [],
122
+ skipped: []
123
+ };
124
+ await ensureRootIsDirectory(args.rootDir);
125
+ for (const relDir of STAGING_SUBDIRS) {
126
+ await ensureDir(join(args.rootDir, relDir));
127
+ result.ensuredDirs.push(relDir);
128
+ }
129
+ // Intentionally capture time once for transactional consistency.
130
+ const nowIso = new Date().toISOString();
131
+ await writeIfMissingOrForce({
132
+ rootDir: args.rootDir,
133
+ relPath: ".checkpoint.json",
134
+ contents: { kind: "json", json: createDefaultCheckpoint(nowIso) },
135
+ force,
136
+ result
137
+ });
138
+ if (!minimal) {
139
+ for (const tmpl of DEFAULT_TEMPLATES) {
140
+ const contents = tmpl.kind === "text"
141
+ ? { kind: "text", text: await loadTemplateText(tmpl.templateName) }
142
+ : { kind: "json", json: await loadTemplateJson(tmpl.templateName) };
143
+ await writeIfMissingOrForce({ rootDir: args.rootDir, relPath: tmpl.relPath, contents, force, result });
144
+ }
145
+ }
146
+ if (platform) {
147
+ const templateProfile = await loadPlatformProfileTemplate(platform);
148
+ await writeIfMissingOrForce({
149
+ rootDir: args.rootDir,
150
+ relPath: "platform-profile.json",
151
+ contents: { kind: "json", json: { ...templateProfile, created_at: nowIso } },
152
+ force,
153
+ result
154
+ });
155
+ // genre-weight-profiles.json is required when platform-profile.json.scoring is present.
156
+ await writeIfMissingOrForce({
157
+ rootDir: args.rootDir,
158
+ relPath: "genre-weight-profiles.json",
159
+ contents: { kind: "json", json: await loadTemplateJson("genre-weight-profiles.json") },
160
+ force,
161
+ result
162
+ });
163
+ }
164
+ return result;
165
+ }
@@ -1,7 +1,8 @@
1
+ import { readdir } from "node:fs/promises";
1
2
  import { join } from "node:path";
2
3
  import { NovelCliError } from "./errors.js";
3
4
  import { ensureDir, pathExists, readJsonFile, readTextFile, writeJsonFile, writeTextFileIfMissing } from "./fs-utils.js";
4
- import { loadContinuityLatestSummary } from "./consistency-auditor.js";
5
+ import { loadContinuityLatestSummary, tryResolveVolumeChapterRange } from "./consistency-auditor.js";
5
6
  import { loadEngagementLatestSummary } from "./engagement.js";
6
7
  import { computeForeshadowVisibilityReport, loadForeshadowGlobalItems } from "./foreshadow-visibility.js";
7
8
  import { computeEffectiveScoringWeights, loadGenreWeightProfiles } from "./scoring-weights.js";
@@ -11,8 +12,10 @@ import { computePrejudgeGuardrailsReport, writePrejudgeGuardrailsReport } from "
11
12
  import { loadPromiseLedgerLatestSummary } from "./promise-ledger.js";
12
13
  import { resolveProjectRelativePath } from "./safe-path.js";
13
14
  import { computeTitlePolicyReport } from "./title-policy.js";
14
- import { chapterRelPaths, formatStepId, pad2, titleFixSnapshotRel } from "./steps.js";
15
+ import { chapterRelPaths, formatStepId, pad2, pad3, titleFixSnapshotRel } from "./steps.js";
15
16
  import { isPlainObject } from "./type-guards.js";
17
+ import { VOL_REVIEW_RELS } from "./volume-review.js";
18
+ import { computeVolumeChapterRange, volumeFinalRelPaths, volumeStagingRelPaths } from "./volume-planning.js";
16
19
  function relIfExists(relPath, exists) {
17
20
  return exists ? relPath : null;
18
21
  }
@@ -23,14 +26,309 @@ function safeEmbedMode(mode) {
23
26
  return "brief";
24
27
  throw new NovelCliError(`Unsupported --embed mode: ${mode}. Supported: brief`, 2);
25
28
  }
29
+ async function buildReviewInstructionPacket(args) {
30
+ const stepId = formatStepId(args.step);
31
+ if (args.step.kind !== "review")
32
+ throw new NovelCliError(`Unsupported review step: ${stepId}`, 2);
33
+ const step = args.step;
34
+ const volume = args.checkpoint.current_volume;
35
+ const embedMode = safeEmbedMode(args.embedMode);
36
+ const embed = {};
37
+ if (embedMode === "brief") {
38
+ const briefAbs = join(args.rootDir, "brief.md");
39
+ if (await pathExists(briefAbs)) {
40
+ const content = await readTextFile(briefAbs);
41
+ embed.brief_preview = content.slice(0, 2000);
42
+ }
43
+ else {
44
+ embed.brief_preview = null;
45
+ }
46
+ }
47
+ const commonPaths = {};
48
+ const maybeAddPath = async (target, key, relPath) => {
49
+ const absPath = join(args.rootDir, relPath);
50
+ if (await pathExists(absPath))
51
+ target[key] = relPath;
52
+ };
53
+ await maybeAddPath(commonPaths, "project_brief", "brief.md");
54
+ await maybeAddPath(commonPaths, "style_profile", "style-profile.json");
55
+ await maybeAddPath(commonPaths, "platform_profile", "platform-profile.json");
56
+ await maybeAddPath(commonPaths, "quality_rubric", "skills/novel-writing/references/quality-rubric.md");
57
+ await maybeAddPath(commonPaths, "foreshadowing_global", "foreshadowing/global.json");
58
+ await maybeAddPath(commonPaths, "storylines", "storylines/storylines.json");
59
+ const volumeOutlineRel = `volumes/vol-${pad2(volume)}/outline.md`;
60
+ if (await pathExists(join(args.rootDir, volumeOutlineRel)))
61
+ commonPaths.volume_outline = volumeOutlineRel;
62
+ // Resolve chapter range: prefer volume contracts/outline, fallback to last 10 chapters.
63
+ const endChapter = args.checkpoint.last_completed_chapter;
64
+ const resolvedRange = (await tryResolveVolumeChapterRange({ rootDir: args.rootDir, volume })) ??
65
+ (Number.isInteger(endChapter) && endChapter >= 1 ? { start: Math.max(1, endChapter - 9), end: endChapter } : null);
66
+ if (!resolvedRange) {
67
+ throw new NovelCliError(`Cannot resolve volume review chapter_range (last_completed_chapter=${String(endChapter)}).`, 2);
68
+ }
69
+ const inline = { volume, chapter_range: [resolvedRange.start, resolvedRange.end] };
70
+ const paths = { ...commonPaths };
71
+ const expected_outputs = [];
72
+ const next_actions = [];
73
+ let agent;
74
+ if (step.phase === "collect") {
75
+ agent = { kind: "cli", name: "volume-review" };
76
+ paths.quality_summary = VOL_REVIEW_RELS.qualitySummary;
77
+ expected_outputs.push({ path: VOL_REVIEW_RELS.qualitySummary, required: true });
78
+ next_actions.push({ kind: "command", command: `novel volume-review collect` });
79
+ next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
80
+ next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
81
+ }
82
+ else if (step.phase === "audit") {
83
+ agent = { kind: "subagent", name: "consistency-auditor" };
84
+ inline.scope = "volume_end";
85
+ inline.stride = 5;
86
+ inline.window = 10;
87
+ // Attach inputs expected by the ConsistencyAuditor agent.
88
+ const chapterPaths = [];
89
+ const contractPaths = [];
90
+ for (let chapter = resolvedRange.start; chapter <= resolvedRange.end; chapter++) {
91
+ const chapterRel = `chapters/chapter-${pad3(chapter)}.md`;
92
+ if (await pathExists(join(args.rootDir, chapterRel)))
93
+ chapterPaths.push(chapterRel);
94
+ const contractRel = `volumes/vol-${pad2(volume)}/chapter-contracts/chapter-${pad3(chapter)}.json`;
95
+ if (await pathExists(join(args.rootDir, contractRel)))
96
+ contractPaths.push(contractRel);
97
+ }
98
+ paths.chapters = chapterPaths;
99
+ if (contractPaths.length > 0)
100
+ paths.chapter_contracts = contractPaths;
101
+ await maybeAddPath(paths, "storyline_spec", "storylines/storyline-spec.json");
102
+ await maybeAddPath(paths, "storyline_schedule", `volumes/vol-${pad2(volume)}/storyline-schedule.json`);
103
+ await maybeAddPath(paths, "state_current", "state/current-state.json");
104
+ await maybeAddPath(paths, "state_changelog", "state/changelog.jsonl");
105
+ // Best-effort: include active character contracts.
106
+ const charsDirRel = "characters/active";
107
+ const charsDirAbs = join(args.rootDir, charsDirRel);
108
+ if (await pathExists(charsDirAbs)) {
109
+ try {
110
+ const entries = await readdir(charsDirAbs, { withFileTypes: true });
111
+ const rels = entries
112
+ .filter((e) => e.isFile() && e.name.endsWith(".json"))
113
+ .map((e) => `${charsDirRel}/${e.name}`)
114
+ .sort();
115
+ if (rels.length > 0)
116
+ paths.characters_active = rels;
117
+ }
118
+ catch {
119
+ inline.characters_active_degraded = true;
120
+ }
121
+ }
122
+ // Optional: pass quality summary to auditor.
123
+ if (await pathExists(join(args.rootDir, VOL_REVIEW_RELS.qualitySummary))) {
124
+ paths.quality_summary = VOL_REVIEW_RELS.qualitySummary;
125
+ }
126
+ expected_outputs.push({
127
+ path: VOL_REVIEW_RELS.auditReport,
128
+ required: true,
129
+ note: "ConsistencyAuditor returns JSON; the executor should write it to this path."
130
+ });
131
+ next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
132
+ next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
133
+ }
134
+ else if (step.phase === "report") {
135
+ agent = { kind: "cli", name: "volume-review" };
136
+ paths.quality_summary = VOL_REVIEW_RELS.qualitySummary;
137
+ paths.audit_report = VOL_REVIEW_RELS.auditReport;
138
+ expected_outputs.push({ path: VOL_REVIEW_RELS.reviewReport, required: true });
139
+ next_actions.push({ kind: "command", command: `novel volume-review report` });
140
+ next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
141
+ next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
142
+ }
143
+ else if (step.phase === "cleanup") {
144
+ agent = { kind: "cli", name: "volume-review" };
145
+ expected_outputs.push({ path: VOL_REVIEW_RELS.foreshadowStatus, required: true });
146
+ next_actions.push({ kind: "command", command: `novel volume-review cleanup` });
147
+ next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
148
+ next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
149
+ }
150
+ else if (step.phase === "transition") {
151
+ agent = { kind: "cli", name: "novel" };
152
+ expected_outputs.push({
153
+ path: "(checkpoint)",
154
+ required: true,
155
+ note: "Advance updates .checkpoint.json: current_volume++, orchestrator_state=WRITING, and clears staging/vol-review/."
156
+ });
157
+ next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
158
+ next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
159
+ }
160
+ else {
161
+ const _exhaustive = step.phase;
162
+ throw new NovelCliError(`Unsupported review phase: ${String(_exhaustive)}`, 2);
163
+ }
164
+ const packet = {
165
+ version: 1,
166
+ step: stepId,
167
+ agent,
168
+ manifest: {
169
+ mode: embedMode === "off" ? "paths" : "paths+embed",
170
+ inline,
171
+ paths,
172
+ ...(embedMode === "off" ? {} : { embed })
173
+ },
174
+ expected_outputs,
175
+ next_actions
176
+ };
177
+ let writtenPath = null;
178
+ if (args.writeManifest) {
179
+ const manifestsDir = join(args.rootDir, "staging/manifests");
180
+ await ensureDir(manifestsDir);
181
+ const fileName = `${stepId.replaceAll(":", "-")}.packet.json`;
182
+ writtenPath = `staging/manifests/${fileName}`;
183
+ await writeJsonFile(join(args.rootDir, writtenPath), packet);
184
+ }
185
+ return {
186
+ packet,
187
+ ...(writtenPath ? { written_manifest_path: writtenPath } : {})
188
+ };
189
+ }
26
190
  export async function buildInstructionPacket(args) {
27
191
  const stepId = formatStepId(args.step);
192
+ if (args.step.kind === "review")
193
+ return await buildReviewInstructionPacket(args);
194
+ if (args.step.kind === "volume") {
195
+ const step = args.step;
196
+ const volume = args.checkpoint.current_volume;
197
+ const range = computeVolumeChapterRange({ current_volume: volume, last_completed_chapter: args.checkpoint.last_completed_chapter });
198
+ const embedMode = safeEmbedMode(args.embedMode);
199
+ const embed = {};
200
+ if (embedMode === "brief") {
201
+ const briefAbs = join(args.rootDir, "brief.md");
202
+ if (await pathExists(briefAbs)) {
203
+ const content = await readTextFile(briefAbs);
204
+ embed.brief_preview = content.slice(0, 2000);
205
+ }
206
+ else {
207
+ embed.brief_preview = null;
208
+ }
209
+ }
210
+ const inline = {
211
+ volume,
212
+ volume_plan: { volume, chapter_range: [range.start, range.end] }
213
+ };
214
+ const paths = {};
215
+ const maybeAddPath = async (key, relPath) => {
216
+ const absPath = join(args.rootDir, relPath);
217
+ if (await pathExists(absPath))
218
+ paths[key] = relPath;
219
+ };
220
+ await maybeAddPath("project_brief", "brief.md");
221
+ await maybeAddPath("style_profile", "style-profile.json");
222
+ await maybeAddPath("platform_profile", "platform-profile.json");
223
+ await maybeAddPath("genre_weight_profiles", "genre-weight-profiles.json");
224
+ await maybeAddPath("style_guide", "skills/novel-writing/references/style-guide.md");
225
+ await maybeAddPath("quality_rubric", "skills/novel-writing/references/quality-rubric.md");
226
+ await maybeAddPath("storylines", "storylines/storylines.json");
227
+ await maybeAddPath("world_rules", "world/rules.json");
228
+ await maybeAddPath("global_foreshadowing", "foreshadowing/global.json");
229
+ if (volume > 1) {
230
+ await maybeAddPath("prev_volume_review", `volumes/vol-${pad2(volume - 1)}/review.md`);
231
+ }
232
+ const worldDirAbs = join(args.rootDir, "world");
233
+ if (await pathExists(worldDirAbs))
234
+ paths.world_docs = "world/*.md";
235
+ const charsDirAbs = join(args.rootDir, "characters/active");
236
+ if (await pathExists(charsDirAbs)) {
237
+ paths.characters_md = "characters/active/*.md";
238
+ paths.characters_contracts = "characters/active/*.json";
239
+ }
240
+ let agent;
241
+ const expected_outputs = [];
242
+ const next_actions = [];
243
+ const staging = volumeStagingRelPaths(volume);
244
+ const final = volumeFinalRelPaths(volume);
245
+ const addPlanningOutputs = (base) => {
246
+ expected_outputs.push({ path: base.outlineMd, required: true });
247
+ expected_outputs.push({ path: base.storylineScheduleJson, required: true });
248
+ expected_outputs.push({ path: base.foreshadowingJson, required: true });
249
+ expected_outputs.push({ path: base.newCharactersJson, required: true });
250
+ for (let ch = range.start; ch <= range.end; ch++) {
251
+ expected_outputs.push({ path: base.chapterContractJson(ch), required: true });
252
+ }
253
+ };
254
+ if (step.phase === "outline") {
255
+ agent = { kind: "subagent", name: "plot-architect" };
256
+ inline.expected_outputs_base_dir = staging.dir;
257
+ addPlanningOutputs(staging);
258
+ next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
259
+ next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
260
+ next_actions.push({ kind: "command", command: `novel instructions volume:validate --json`, note: "After advance, proceed to validate/approve." });
261
+ }
262
+ else if (step.phase === "validate") {
263
+ agent = { kind: "cli", name: "manual-review" };
264
+ inline.review_targets = {
265
+ outline: staging.outlineMd,
266
+ storyline_schedule: staging.storylineScheduleJson,
267
+ foreshadowing: staging.foreshadowingJson,
268
+ new_characters: staging.newCharactersJson,
269
+ chapter_contracts_dir: staging.chapterContractsDir
270
+ };
271
+ addPlanningOutputs(staging);
272
+ next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
273
+ next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
274
+ next_actions.push({ kind: "command", command: `novel instructions volume:commit --json`, note: "After approval, proceed to commit." });
275
+ }
276
+ else if (step.phase === "commit") {
277
+ agent = { kind: "cli", name: "novel" };
278
+ inline.commit_from = staging.dir;
279
+ inline.commit_to = final.dir;
280
+ addPlanningOutputs(final);
281
+ next_actions.push({ kind: "command", command: `novel commit --volume ${volume}` });
282
+ next_actions.push({ kind: "command", command: `novel next`, note: "After commit, resume the writing pipeline." });
283
+ }
284
+ else {
285
+ const _exhaustive = step.phase;
286
+ throw new NovelCliError(`Unsupported volume phase: ${_exhaustive}`, 2);
287
+ }
288
+ const gate = args.novelAskGate ?? null;
289
+ const gateSpec = gate ? parseNovelAskQuestionSpec(gate.novel_ask) : null;
290
+ if (gate) {
291
+ resolveProjectRelativePath(args.rootDir, gate.answer_path, "novelAskGate.answer_path");
292
+ expected_outputs.unshift({
293
+ path: gate.answer_path,
294
+ required: true,
295
+ note: "AnswerSpec JSON record for the NOVEL_ASK gate (written before main step execution)."
296
+ });
297
+ }
298
+ const packet = {
299
+ version: 1,
300
+ step: stepId,
301
+ agent,
302
+ ...(gate ? { novel_ask: gateSpec, answer_path: gate.answer_path } : {}),
303
+ manifest: {
304
+ mode: embedMode === "off" ? "paths" : "paths+embed",
305
+ inline,
306
+ paths,
307
+ ...(embedMode === "off" ? {} : { embed })
308
+ },
309
+ expected_outputs,
310
+ next_actions
311
+ };
312
+ let writtenPath = null;
313
+ if (args.writeManifest) {
314
+ const manifestsDir = join(args.rootDir, "staging/manifests");
315
+ await ensureDir(manifestsDir);
316
+ const fileName = `${stepId.replaceAll(":", "-")}.packet.json`;
317
+ writtenPath = `staging/manifests/${fileName}`;
318
+ await writeJsonFile(join(args.rootDir, writtenPath), packet);
319
+ }
320
+ return {
321
+ packet,
322
+ ...(writtenPath ? { written_manifest_path: writtenPath } : {})
323
+ };
324
+ }
28
325
  if (args.step.kind !== "chapter")
29
326
  throw new NovelCliError(`Unsupported step: ${stepId}`, 2);
327
+ const step = args.step;
30
328
  const volume = args.checkpoint.current_volume;
31
329
  const volumeOutlineRel = `volumes/vol-${pad2(volume)}/outline.md`;
32
- const chapterContractRel = `volumes/vol-${pad2(volume)}/chapter-contracts/chapter-${String(args.step.chapter).padStart(3, "0")}.json`;
33
- const rel = chapterRelPaths(args.step.chapter);
330
+ const chapterContractRel = `volumes/vol-${pad2(volume)}/chapter-contracts/chapter-${String(step.chapter).padStart(3, "0")}.json`;
331
+ const rel = chapterRelPaths(step.chapter);
34
332
  const commonPaths = {};
35
333
  const maybeAddPath = async (target, key, relPath) => {
36
334
  const absPath = join(args.rootDir, relPath);
@@ -67,7 +365,7 @@ export async function buildInstructionPacket(args) {
67
365
  }
68
366
  }
69
367
  let agent;
70
- const inline = { chapter: args.step.chapter, volume };
368
+ const inline = { chapter: step.chapter, volume };
71
369
  const paths = { ...commonPaths };
72
370
  const expected_outputs = [];
73
371
  const next_actions = [];
@@ -138,7 +436,7 @@ export async function buildInstructionPacket(args) {
138
436
  inline.promise_ledger_report_summary_degraded = true;
139
437
  }
140
438
  };
141
- if (args.step.stage === "draft") {
439
+ if (step.stage === "draft") {
142
440
  agent = { kind: "subagent", name: "chapter-writer" };
143
441
  // Optional: inject character voice drift directives (best-effort).
144
442
  await maybeAttachCharacterVoiceDirectives();
@@ -163,7 +461,7 @@ export async function buildInstructionPacket(args) {
163
461
  const items = await loadForeshadowGlobalItems(args.rootDir);
164
462
  const report = computeForeshadowVisibilityReport({
165
463
  items,
166
- asOfChapter: args.step.chapter,
464
+ asOfChapter: step.chapter,
167
465
  volume,
168
466
  platform,
169
467
  genreDriveType
@@ -186,11 +484,11 @@ export async function buildInstructionPacket(args) {
186
484
  next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
187
485
  next_actions.push({
188
486
  kind: "command",
189
- command: `novel instructions chapter:${String(args.step.chapter).padStart(3, "0")}:summarize --json`,
487
+ command: `novel instructions chapter:${String(step.chapter).padStart(3, "0")}:summarize --json`,
190
488
  note: "After advance, proceed to summarize."
191
489
  });
192
490
  }
193
- else if (args.step.stage === "summarize") {
491
+ else if (step.stage === "summarize") {
194
492
  agent = { kind: "subagent", name: "summarizer" };
195
493
  paths.chapter_draft = relIfExists(rel.staging.chapterMd, await pathExists(join(args.rootDir, rel.staging.chapterMd)));
196
494
  expected_outputs.push({ path: rel.staging.summaryMd, required: true });
@@ -200,7 +498,7 @@ export async function buildInstructionPacket(args) {
200
498
  next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
201
499
  next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
202
500
  }
203
- else if (args.step.stage === "refine") {
501
+ else if (step.stage === "refine") {
204
502
  agent = { kind: "subagent", name: "style-refiner" };
205
503
  // Optional: inject character voice drift directives (best-effort).
206
504
  await maybeAttachCharacterVoiceDirectives();
@@ -223,7 +521,7 @@ export async function buildInstructionPacket(args) {
223
521
  next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
224
522
  next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
225
523
  }
226
- else if (args.step.stage === "judge") {
524
+ else if (step.stage === "judge") {
227
525
  agent = { kind: "subagent", name: "quality-judge" };
228
526
  const chapterDraftRel = relIfExists(rel.staging.chapterMd, await pathExists(join(args.rootDir, rel.staging.chapterMd)));
229
527
  paths.chapter_draft = chapterDraftRel;
@@ -252,12 +550,12 @@ export async function buildInstructionPacket(args) {
252
550
  try {
253
551
  const report = await computePrejudgeGuardrailsReport({
254
552
  rootDir: args.rootDir,
255
- chapter: args.step.chapter,
553
+ chapter: step.chapter,
256
554
  chapterAbsPath: join(args.rootDir, chapterDraftRel),
257
555
  platformProfileRelPath: loadedPlatform.relPath,
258
556
  platformProfile: loadedPlatform.profile
259
557
  });
260
- const { relPath } = await writePrejudgeGuardrailsReport({ rootDir: args.rootDir, chapter: args.step.chapter, report });
558
+ const { relPath } = await writePrejudgeGuardrailsReport({ rootDir: args.rootDir, chapter: step.chapter, report });
261
559
  paths.prejudge_guardrails = relPath;
262
560
  inline.prejudge_guardrails = {
263
561
  status: report.status,
@@ -283,7 +581,7 @@ export async function buildInstructionPacket(args) {
283
581
  note: "After advance, compute the next deterministic step (may be title-fix/hook-fix/review/commit)."
284
582
  });
285
583
  }
286
- else if (args.step.stage === "title-fix") {
584
+ else if (step.stage === "title-fix") {
287
585
  agent = { kind: "subagent", name: "chapter-writer" };
288
586
  paths.chapter_draft = relIfExists(rel.staging.chapterMd, await pathExists(join(args.rootDir, rel.staging.chapterMd)));
289
587
  inline.fix_mode = "title-fix";
@@ -297,10 +595,10 @@ export async function buildInstructionPacket(args) {
297
595
  // Snapshot the chapter before title-fix so validate can ensure body is byte-identical.
298
596
  const beforeAbs = join(args.rootDir, rel.staging.chapterMd);
299
597
  const before = await readTextFile(beforeAbs);
300
- const snapshotRel = titleFixSnapshotRel(args.step.chapter);
598
+ const snapshotRel = titleFixSnapshotRel(step.chapter);
301
599
  await writeTextFileIfMissing(join(args.rootDir, snapshotRel), before);
302
600
  paths.title_fix_before = snapshotRel;
303
- const report = computeTitlePolicyReport({ chapter: args.step.chapter, chapterText: before, platformProfile: loadedPlatform.profile });
601
+ const report = computeTitlePolicyReport({ chapter: step.chapter, chapterText: before, platformProfile: loadedPlatform.profile });
304
602
  inline.title_policy = {
305
603
  enabled: titlePolicy.enabled,
306
604
  min_chars: titlePolicy.min_chars,
@@ -328,7 +626,7 @@ export async function buildInstructionPacket(args) {
328
626
  note: "After title-fix, compute the next deterministic step (typically judge)."
329
627
  });
330
628
  }
331
- else if (args.step.stage === "hook-fix") {
629
+ else if (step.stage === "hook-fix") {
332
630
  agent = { kind: "subagent", name: "chapter-writer" };
333
631
  paths.chapter_draft = relIfExists(rel.staging.chapterMd, await pathExists(join(args.rootDir, rel.staging.chapterMd)));
334
632
  paths.chapter_eval = relIfExists(rel.staging.evalJson, await pathExists(join(args.rootDir, rel.staging.evalJson)));
@@ -344,28 +642,28 @@ export async function buildInstructionPacket(args) {
344
642
  next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
345
643
  next_actions.push({
346
644
  kind: "command",
347
- command: `novel instructions chapter:${String(args.step.chapter).padStart(3, "0")}:judge --json`,
645
+ command: `novel instructions chapter:${String(step.chapter).padStart(3, "0")}:judge --json`,
348
646
  note: "After hook-fix, re-run QualityJudge to refresh eval."
349
647
  });
350
648
  }
351
- else if (args.step.stage === "review") {
649
+ else if (step.stage === "review") {
352
650
  agent = { kind: "cli", name: "manual-review" };
353
651
  paths.chapter_draft = relIfExists(rel.staging.chapterMd, await pathExists(join(args.rootDir, rel.staging.chapterMd)));
354
652
  paths.chapter_eval = relIfExists(rel.staging.evalJson, await pathExists(join(args.rootDir, rel.staging.evalJson)));
355
653
  expected_outputs.push({ path: "(manual)", required: false, note: "Review required: guardrails still failing after bounded auto-fix." });
356
654
  next_actions.push({
357
655
  kind: "command",
358
- command: `novel instructions chapter:${String(args.step.chapter).padStart(3, "0")}:judge --json`,
656
+ command: `novel instructions chapter:${String(step.chapter).padStart(3, "0")}:judge --json`,
359
657
  note: "After manually fixing the chapter (title/hook/etc), re-run QualityJudge."
360
658
  });
361
659
  }
362
- else if (args.step.stage === "commit") {
660
+ else if (step.stage === "commit") {
363
661
  agent = { kind: "cli", name: "novel" };
364
- expected_outputs.push({ path: `chapters/chapter-${String(args.step.chapter).padStart(3, "0")}.md`, required: true });
365
- next_actions.push({ kind: "command", command: `novel commit --chapter ${args.step.chapter}` });
662
+ expected_outputs.push({ path: `chapters/chapter-${String(step.chapter).padStart(3, "0")}.md`, required: true });
663
+ next_actions.push({ kind: "command", command: `novel commit --chapter ${step.chapter}` });
366
664
  }
367
665
  else {
368
- throw new NovelCliError(`Unsupported step stage: ${args.step.stage}`, 2);
666
+ throw new NovelCliError(`Unsupported step stage: ${step.stage}`, 2);
369
667
  }
370
668
  const gate = args.novelAskGate ?? null;
371
669
  const gateSpec = gate ? parseNovelAskQuestionSpec(gate.novel_ask) : null;