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.
- package/README.md +9 -1
- package/dist/__tests__/advance-refine-invalidates-eval.test.js +37 -0
- package/dist/__tests__/character-voice.test.js +1 -1
- package/dist/__tests__/gate-decision.test.js +66 -0
- package/dist/__tests__/init.test.js +245 -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 +168 -0
- package/dist/__tests__/orchestrator-state-write-path.test.js +59 -0
- package/dist/__tests__/steps-id.test.js +23 -0
- package/dist/__tests__/volume-pipeline.test.js +227 -0
- package/dist/__tests__/volume-review-pipeline.test.js +112 -0
- package/dist/__tests__/volume-review-storyline-rhythm.test.js +19 -0
- package/dist/advance.js +145 -48
- package/dist/checkpoint.js +83 -12
- package/dist/cli.js +235 -8
- package/dist/commit.js +1 -0
- package/dist/fs-utils.js +18 -3
- package/dist/gate-decision.js +59 -0
- package/dist/init.js +165 -0
- package/dist/instructions.js +322 -24
- package/dist/next-step.js +198 -34
- package/dist/platform-profile.js +3 -0
- package/dist/steps.js +60 -17
- package/dist/validate.js +275 -2
- package/dist/volume-commit.js +101 -0
- package/dist/volume-planning.js +143 -0
- package/dist/volume-review.js +448 -0
- package/docs/user/novel-cli.md +57 -0
- package/package.json +3 -2
- 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
|
+
}
|
package/dist/instructions.js
CHANGED
|
@@ -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(
|
|
33
|
-
const rel = chapterRelPaths(
|
|
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:
|
|
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 (
|
|
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:
|
|
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(
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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:
|
|
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:
|
|
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 (
|
|
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(
|
|
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:
|
|
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 (
|
|
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(
|
|
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 (
|
|
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(
|
|
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 (
|
|
660
|
+
else if (step.stage === "commit") {
|
|
363
661
|
agent = { kind: "cli", name: "novel" };
|
|
364
|
-
expected_outputs.push({ path: `chapters/chapter-${String(
|
|
365
|
-
next_actions.push({ kind: "command", command: `novel commit --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: ${
|
|
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;
|