novel-writer-cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/agents/chapter-writer.md +142 -0
- package/agents/character-weaver.md +117 -0
- package/agents/consistency-auditor.md +85 -0
- package/agents/plot-architect.md +128 -0
- package/agents/quality-judge.md +232 -0
- package/agents/style-analyzer.md +109 -0
- package/agents/style-refiner.md +97 -0
- package/agents/summarizer.md +128 -0
- package/agents/world-builder.md +161 -0
- package/dist/__tests__/character-voice.test.js +445 -0
- package/dist/__tests__/commit-prototype-pollution.test.js +45 -0
- package/dist/__tests__/engagement.test.js +382 -0
- package/dist/__tests__/foreshadow-visibility.test.js +131 -0
- package/dist/__tests__/hook-ledger.test.js +1028 -0
- package/dist/__tests__/naming-lint.test.js +132 -0
- package/dist/__tests__/narrative-health-injection.test.js +359 -0
- package/dist/__tests__/next-step-prejudge-guardrails.test.js +325 -0
- package/dist/__tests__/next-step-title-fix.test.js +153 -0
- package/dist/__tests__/platform-profile.test.js +274 -0
- package/dist/__tests__/promise-ledger.test.js +189 -0
- package/dist/__tests__/readability-lint.test.js +209 -0
- package/dist/__tests__/text-utils.test.js +39 -0
- package/dist/__tests__/title-policy.test.js +147 -0
- package/dist/advance.js +75 -0
- package/dist/character-voice.js +805 -0
- package/dist/checkpoint.js +126 -0
- package/dist/cli.js +563 -0
- package/dist/cliche-lint.js +515 -0
- package/dist/commit.js +1460 -0
- package/dist/consistency-auditor.js +684 -0
- package/dist/engagement.js +687 -0
- package/dist/errors.js +7 -0
- package/dist/fingerprint.js +16 -0
- package/dist/foreshadow-visibility.js +214 -0
- package/dist/fs-utils.js +68 -0
- package/dist/hook-ledger.js +721 -0
- package/dist/hook-policy.js +107 -0
- package/dist/instruction-gates.js +51 -0
- package/dist/instructions.js +406 -0
- package/dist/latest-summary-loader.js +29 -0
- package/dist/lock.js +121 -0
- package/dist/naming-lint.js +531 -0
- package/dist/ner.js +73 -0
- package/dist/next-step.js +408 -0
- package/dist/novel-ask.js +270 -0
- package/dist/output.js +9 -0
- package/dist/platform-constraints.js +518 -0
- package/dist/platform-profile.js +325 -0
- package/dist/prejudge-guardrails.js +370 -0
- package/dist/project.js +40 -0
- package/dist/promise-ledger.js +723 -0
- package/dist/readability-lint.js +555 -0
- package/dist/safe-parse.js +36 -0
- package/dist/safe-path.js +29 -0
- package/dist/scoring-weights.js +290 -0
- package/dist/steps.js +60 -0
- package/dist/text-utils.js +18 -0
- package/dist/title-policy.js +251 -0
- package/dist/type-guards.js +6 -0
- package/dist/validate.js +131 -0
- package/docs/user/README.md +17 -0
- package/docs/user/guardrails.md +179 -0
- package/docs/user/interactive-gates.md +124 -0
- package/docs/user/novel-cli.md +289 -0
- package/docs/user/ops.md +123 -0
- package/docs/user/quick-start.md +97 -0
- package/docs/user/spec-system.md +166 -0
- package/docs/user/storylines.md +144 -0
- package/package.json +48 -0
- package/schemas/README.md +18 -0
- package/schemas/character-voice-drift.schema.json +135 -0
- package/schemas/character-voice-profiles.schema.json +141 -0
- package/schemas/engagement-metrics.schema.json +38 -0
- package/schemas/hook-ledger.schema.json +108 -0
- package/schemas/platform-profile.schema.json +235 -0
- package/schemas/promise-ledger.schema.json +97 -0
- package/scripts/calibrate-quality-judge.sh +91 -0
- package/scripts/compare-regression-runs.sh +86 -0
- package/scripts/lib/_common.py +131 -0
- package/scripts/lib/calibrate_quality_judge.py +312 -0
- package/scripts/lib/compare_regression_runs.py +142 -0
- package/scripts/lib/run_regression.py +621 -0
- package/scripts/lint-blacklist.sh +201 -0
- package/scripts/lint-cliche.sh +370 -0
- package/scripts/lint-readability.sh +404 -0
- package/scripts/query-foreshadow.sh +252 -0
- package/scripts/run-ner.sh +669 -0
- package/scripts/run-regression.sh +122 -0
- package/skills/cli-step/SKILL.md +158 -0
- package/skills/continue/SKILL.md +348 -0
- package/skills/continue/references/context-contracts.md +169 -0
- package/skills/continue/references/continuity-checks.md +187 -0
- package/skills/continue/references/file-protocols.md +64 -0
- package/skills/continue/references/foreshadowing.md +130 -0
- package/skills/continue/references/gate-decision.md +53 -0
- package/skills/continue/references/periodic-maintenance.md +46 -0
- package/skills/novel-writing/SKILL.md +77 -0
- package/skills/novel-writing/references/quality-rubric.md +140 -0
- package/skills/novel-writing/references/style-guide.md +145 -0
- package/skills/start/SKILL.md +458 -0
- package/skills/start/references/quality-review.md +86 -0
- package/skills/start/references/setting-update.md +44 -0
- package/skills/start/references/vol-planning.md +61 -0
- package/skills/start/references/vol-review.md +58 -0
- package/skills/status/SKILL.md +116 -0
- package/skills/status/references/sample-output.md +60 -0
- package/templates/ai-blacklist.json +79 -0
- package/templates/brief-template.md +46 -0
- package/templates/genre-weight-profiles.json +90 -0
- package/templates/novel-ask/example.answer.json +12 -0
- package/templates/novel-ask/example.question.json +51 -0
- package/templates/platform-profile.json +148 -0
- package/templates/style-profile-template.json +58 -0
- package/templates/web-novel-cliche-lint.json +41 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { isPlainObject } from "./type-guards.js";
|
|
2
|
+
function asBool(value) {
|
|
3
|
+
return typeof value === "boolean" ? value : null;
|
|
4
|
+
}
|
|
5
|
+
function asNumber(value) {
|
|
6
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
7
|
+
}
|
|
8
|
+
function asString(value) {
|
|
9
|
+
if (typeof value !== "string")
|
|
10
|
+
return null;
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
13
|
+
}
|
|
14
|
+
function extractHookStrength(evalObj) {
|
|
15
|
+
const scoresRaw = evalObj.scores;
|
|
16
|
+
if (isPlainObject(scoresRaw)) {
|
|
17
|
+
const hsRaw = scoresRaw.hook_strength;
|
|
18
|
+
if (isPlainObject(hsRaw)) {
|
|
19
|
+
const score = asNumber(hsRaw.score);
|
|
20
|
+
const evidence = asString(hsRaw.evidence);
|
|
21
|
+
return { strength: score, evidence };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const legacyStrength = asNumber(evalObj.hook_strength);
|
|
25
|
+
if (legacyStrength !== null)
|
|
26
|
+
return { strength: legacyStrength, evidence: null };
|
|
27
|
+
const hookRaw = evalObj.hook;
|
|
28
|
+
if (isPlainObject(hookRaw)) {
|
|
29
|
+
const strength = asNumber(hookRaw.strength);
|
|
30
|
+
const evidence = asString(hookRaw.evidence);
|
|
31
|
+
return { strength, evidence };
|
|
32
|
+
}
|
|
33
|
+
return { strength: null, evidence: null };
|
|
34
|
+
}
|
|
35
|
+
function extractHook(evalObj) {
|
|
36
|
+
const hookRaw = evalObj.hook;
|
|
37
|
+
if (!isPlainObject(hookRaw))
|
|
38
|
+
return { present: null, type: null, evidence: null };
|
|
39
|
+
const hookObj = hookRaw;
|
|
40
|
+
return {
|
|
41
|
+
present: asBool(hookObj.present),
|
|
42
|
+
type: asString(hookObj.type),
|
|
43
|
+
evidence: asString(hookObj.evidence)
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function strengthInRange(strength) {
|
|
47
|
+
return strength !== null && strength >= 1 && strength <= 5;
|
|
48
|
+
}
|
|
49
|
+
export function checkHookPolicy(args) {
|
|
50
|
+
if (!args.hookPolicy.required) {
|
|
51
|
+
return { status: "skipped", reason: "hook_policy.required=false" };
|
|
52
|
+
}
|
|
53
|
+
if (!isPlainObject(args.evalRaw)) {
|
|
54
|
+
return {
|
|
55
|
+
status: "invalid_eval",
|
|
56
|
+
reason: "eval is not a JSON object",
|
|
57
|
+
extracted: { present: null, type: null, evidence: null, strength: null }
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const root = args.evalRaw;
|
|
61
|
+
const evalObj = isPlainObject(root.eval_used) ? root.eval_used : root;
|
|
62
|
+
const { strength, evidence: strengthEvidence } = extractHookStrength(evalObj);
|
|
63
|
+
const hook = extractHook(evalObj);
|
|
64
|
+
const hookType = hook.type !== null ? hook.type.toLowerCase() : null;
|
|
65
|
+
const extracted = { ...hook, type: hookType, strength };
|
|
66
|
+
if (!strengthInRange(strength)) {
|
|
67
|
+
return { status: "invalid_eval", reason: "missing or invalid hook_strength (expected 1-5)", extracted };
|
|
68
|
+
}
|
|
69
|
+
if (hook.present === null) {
|
|
70
|
+
return { status: "invalid_eval", reason: "missing hook.present (expected boolean)", extracted };
|
|
71
|
+
}
|
|
72
|
+
if (hookType === null) {
|
|
73
|
+
return { status: "invalid_eval", reason: "missing hook.type (expected non-empty string or 'none')", extracted };
|
|
74
|
+
}
|
|
75
|
+
const evidence = hook.evidence ?? strengthEvidence;
|
|
76
|
+
if (evidence === null) {
|
|
77
|
+
return { status: "invalid_eval", reason: "missing hook evidence snippet (expected hook.evidence or scores.hook_strength.evidence)", extracted };
|
|
78
|
+
}
|
|
79
|
+
const allowed = Array.isArray(args.hookPolicy.allowed_types)
|
|
80
|
+
? args.hookPolicy.allowed_types
|
|
81
|
+
.map((t) => (typeof t === "string" ? t.trim().toLowerCase() : ""))
|
|
82
|
+
.filter((t) => t.length > 0)
|
|
83
|
+
: [];
|
|
84
|
+
const minStrength = typeof args.hookPolicy.min_strength === "number" ? args.hookPolicy.min_strength : 1;
|
|
85
|
+
if (!hook.present || hookType === "none") {
|
|
86
|
+
return {
|
|
87
|
+
status: "fail",
|
|
88
|
+
reason: "missing chapter-end hook",
|
|
89
|
+
extracted: { ...extracted, evidence }
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (allowed.length > 0 && !allowed.includes(hookType)) {
|
|
93
|
+
return {
|
|
94
|
+
status: "fail",
|
|
95
|
+
reason: `hook.type not allowed: ${hookType}`,
|
|
96
|
+
extracted: { ...extracted, evidence }
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (strength < minStrength) {
|
|
100
|
+
return {
|
|
101
|
+
status: "fail",
|
|
102
|
+
reason: `hook_strength ${strength} < min_strength ${minStrength}`,
|
|
103
|
+
extracted: { ...extracted, evidence }
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { status: "pass", reason: "hook policy satisfied", extracted: { ...extracted, evidence } };
|
|
107
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { NovelCliError } from "./errors.js";
|
|
2
|
+
import { pathExists, readJsonFile } from "./fs-utils.js";
|
|
3
|
+
import { parseNovelAskAnswerSpec, parseNovelAskQuestionSpec, validateNovelAskAnswerAgainstQuestionSpec } from "./novel-ask.js";
|
|
4
|
+
import { rejectPathTraversalInput, resolveProjectRelativePath } from "./safe-path.js";
|
|
5
|
+
export function extractNovelAskGate(packet) {
|
|
6
|
+
const hasAsk = packet.novel_ask !== undefined;
|
|
7
|
+
const hasPath = packet.answer_path !== undefined;
|
|
8
|
+
if (!hasAsk && !hasPath)
|
|
9
|
+
return null;
|
|
10
|
+
if (hasAsk && !hasPath)
|
|
11
|
+
throw new NovelCliError("Invalid instruction packet: 'answer_path' is required when 'novel_ask' is present.", 2);
|
|
12
|
+
if (!hasAsk && hasPath)
|
|
13
|
+
throw new NovelCliError("Invalid instruction packet: 'novel_ask' is required when 'answer_path' is present.", 2);
|
|
14
|
+
const answer_path = packet.answer_path;
|
|
15
|
+
if (typeof answer_path !== "string" || answer_path.trim().length === 0) {
|
|
16
|
+
throw new NovelCliError("Invalid instruction packet: 'answer_path' must be a non-empty string.", 2);
|
|
17
|
+
}
|
|
18
|
+
rejectPathTraversalInput(answer_path, "answer_path");
|
|
19
|
+
const novel_ask = parseNovelAskQuestionSpec(packet.novel_ask);
|
|
20
|
+
return { novel_ask, answer_path };
|
|
21
|
+
}
|
|
22
|
+
export async function loadNovelAskAnswerIfPresent(rootDir, gate) {
|
|
23
|
+
const questionSpec = parseNovelAskQuestionSpec(gate.novel_ask);
|
|
24
|
+
const absAnswer = resolveProjectRelativePath(rootDir, gate.answer_path, "answer_path");
|
|
25
|
+
if (!(await pathExists(absAnswer)))
|
|
26
|
+
return null;
|
|
27
|
+
const raw = await readJsonFile(absAnswer);
|
|
28
|
+
let answerSpec;
|
|
29
|
+
try {
|
|
30
|
+
answerSpec = parseNovelAskAnswerSpec(raw);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
34
|
+
throw new NovelCliError(`Invalid AnswerSpec at ${gate.answer_path}: ${message}`, 2);
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
validateNovelAskAnswerAgainstQuestionSpec(questionSpec, answerSpec);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
41
|
+
throw new NovelCliError(`Invalid AnswerSpec at ${gate.answer_path}: ${message}`, 2);
|
|
42
|
+
}
|
|
43
|
+
return answerSpec;
|
|
44
|
+
}
|
|
45
|
+
export async function requireNovelAskAnswer(rootDir, gate) {
|
|
46
|
+
const answer = await loadNovelAskAnswerIfPresent(rootDir, gate);
|
|
47
|
+
if (answer === null) {
|
|
48
|
+
throw new NovelCliError(`Step is blocked by NOVEL_ASK gate: missing AnswerSpec record at ${gate.answer_path}. Complete the gate and write a valid AnswerSpec JSON record to this path.`, 2);
|
|
49
|
+
}
|
|
50
|
+
return answer;
|
|
51
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { NovelCliError } from "./errors.js";
|
|
3
|
+
import { ensureDir, pathExists, readJsonFile, readTextFile, writeJsonFile, writeTextFileIfMissing } from "./fs-utils.js";
|
|
4
|
+
import { loadContinuityLatestSummary } from "./consistency-auditor.js";
|
|
5
|
+
import { loadEngagementLatestSummary } from "./engagement.js";
|
|
6
|
+
import { computeForeshadowVisibilityReport, loadForeshadowGlobalItems } from "./foreshadow-visibility.js";
|
|
7
|
+
import { computeEffectiveScoringWeights, loadGenreWeightProfiles } from "./scoring-weights.js";
|
|
8
|
+
import { parseNovelAskQuestionSpec } from "./novel-ask.js";
|
|
9
|
+
import { loadPlatformProfile } from "./platform-profile.js";
|
|
10
|
+
import { computePrejudgeGuardrailsReport, writePrejudgeGuardrailsReport } from "./prejudge-guardrails.js";
|
|
11
|
+
import { loadPromiseLedgerLatestSummary } from "./promise-ledger.js";
|
|
12
|
+
import { resolveProjectRelativePath } from "./safe-path.js";
|
|
13
|
+
import { computeTitlePolicyReport } from "./title-policy.js";
|
|
14
|
+
import { chapterRelPaths, formatStepId, pad2, titleFixSnapshotRel } from "./steps.js";
|
|
15
|
+
import { isPlainObject } from "./type-guards.js";
|
|
16
|
+
function relIfExists(relPath, exists) {
|
|
17
|
+
return exists ? relPath : null;
|
|
18
|
+
}
|
|
19
|
+
function safeEmbedMode(mode) {
|
|
20
|
+
if (!mode)
|
|
21
|
+
return "off";
|
|
22
|
+
if (mode === "brief")
|
|
23
|
+
return "brief";
|
|
24
|
+
throw new NovelCliError(`Unsupported --embed mode: ${mode}. Supported: brief`, 2);
|
|
25
|
+
}
|
|
26
|
+
export async function buildInstructionPacket(args) {
|
|
27
|
+
const stepId = formatStepId(args.step);
|
|
28
|
+
if (args.step.kind !== "chapter")
|
|
29
|
+
throw new NovelCliError(`Unsupported step: ${stepId}`, 2);
|
|
30
|
+
const volume = args.checkpoint.current_volume;
|
|
31
|
+
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);
|
|
34
|
+
const commonPaths = {};
|
|
35
|
+
const maybeAddPath = async (target, key, relPath) => {
|
|
36
|
+
const absPath = join(args.rootDir, relPath);
|
|
37
|
+
if (await pathExists(absPath))
|
|
38
|
+
target[key] = relPath;
|
|
39
|
+
};
|
|
40
|
+
await maybeAddPath(commonPaths, "project_brief", "brief.md");
|
|
41
|
+
await maybeAddPath(commonPaths, "style_profile", "style-profile.json");
|
|
42
|
+
await maybeAddPath(commonPaths, "platform_profile", "platform-profile.json");
|
|
43
|
+
await maybeAddPath(commonPaths, "ai_blacklist", "ai-blacklist.json");
|
|
44
|
+
await maybeAddPath(commonPaths, "web_novel_cliche_lint", "web-novel-cliche-lint.json");
|
|
45
|
+
await maybeAddPath(commonPaths, "genre_weight_profiles", "genre-weight-profiles.json");
|
|
46
|
+
await maybeAddPath(commonPaths, "style_guide", "skills/novel-writing/references/style-guide.md");
|
|
47
|
+
await maybeAddPath(commonPaths, "quality_rubric", "skills/novel-writing/references/quality-rubric.md");
|
|
48
|
+
await maybeAddPath(commonPaths, "current_state", "state/current-state.json");
|
|
49
|
+
await maybeAddPath(commonPaths, "world_rules", "world/rules.json");
|
|
50
|
+
await maybeAddPath(commonPaths, "character_voice_profiles", "character-voice-profiles.json");
|
|
51
|
+
await maybeAddPath(commonPaths, "character_voice_drift", "character-voice-drift.json");
|
|
52
|
+
// Optional: volume outline and chapter contract.
|
|
53
|
+
if (await pathExists(join(args.rootDir, volumeOutlineRel)))
|
|
54
|
+
commonPaths.volume_outline = volumeOutlineRel;
|
|
55
|
+
if (await pathExists(join(args.rootDir, chapterContractRel)))
|
|
56
|
+
commonPaths.chapter_contract = chapterContractRel;
|
|
57
|
+
const embedMode = safeEmbedMode(args.embedMode);
|
|
58
|
+
const embed = {};
|
|
59
|
+
if (embedMode === "brief") {
|
|
60
|
+
const briefAbs = join(args.rootDir, "brief.md");
|
|
61
|
+
if (await pathExists(briefAbs)) {
|
|
62
|
+
const content = await readTextFile(briefAbs);
|
|
63
|
+
embed.brief_preview = content.slice(0, 2000);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
embed.brief_preview = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
let agent;
|
|
70
|
+
const inline = { chapter: args.step.chapter, volume };
|
|
71
|
+
const paths = { ...commonPaths };
|
|
72
|
+
const expected_outputs = [];
|
|
73
|
+
const next_actions = [];
|
|
74
|
+
const maybeAttachCharacterVoiceDirectives = async () => {
|
|
75
|
+
const driftAbs = join(args.rootDir, "character-voice-drift.json");
|
|
76
|
+
if (!(await pathExists(driftAbs)))
|
|
77
|
+
return;
|
|
78
|
+
try {
|
|
79
|
+
const raw = await readJsonFile(driftAbs);
|
|
80
|
+
if (!isPlainObject(raw)) {
|
|
81
|
+
inline.character_voice_drift_degraded = true;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const obj = raw;
|
|
85
|
+
if (obj.schema_version !== 1)
|
|
86
|
+
return;
|
|
87
|
+
const charsRaw = obj.characters;
|
|
88
|
+
if (!Array.isArray(charsRaw))
|
|
89
|
+
return;
|
|
90
|
+
const safeInt = (v) => (typeof v === "number" && Number.isInteger(v) ? v : null);
|
|
91
|
+
const asOf = isPlainObject(obj.as_of) ? obj.as_of : null;
|
|
92
|
+
const window = isPlainObject(obj.window) ? obj.window : null;
|
|
93
|
+
const directives = charsRaw
|
|
94
|
+
.filter(isPlainObject)
|
|
95
|
+
.map((it) => {
|
|
96
|
+
const character_id = typeof it.character_id === "string" && it.character_id.trim().length > 0 ? it.character_id.trim() : null;
|
|
97
|
+
if (!character_id)
|
|
98
|
+
return null;
|
|
99
|
+
const display_name = typeof it.display_name === "string" && it.display_name.trim().length > 0 ? it.display_name.trim() : character_id;
|
|
100
|
+
const rawDirectives = it.directives;
|
|
101
|
+
const lines = Array.isArray(rawDirectives)
|
|
102
|
+
? rawDirectives.filter((d) => typeof d === "string" && d.trim().length > 0).map((d) => d.trim())
|
|
103
|
+
: [];
|
|
104
|
+
if (lines.length === 0)
|
|
105
|
+
return null;
|
|
106
|
+
return { character_id, display_name, directives: lines };
|
|
107
|
+
})
|
|
108
|
+
.filter((it) => it !== null);
|
|
109
|
+
if (directives.length === 0)
|
|
110
|
+
return;
|
|
111
|
+
inline.character_voice_drift = {
|
|
112
|
+
as_of: asOf ? { chapter: safeInt(asOf.chapter), volume: safeInt(asOf.volume) } : null,
|
|
113
|
+
window: window
|
|
114
|
+
? { chapter_start: safeInt(window.chapter_start), chapter_end: safeInt(window.chapter_end), window_chapters: safeInt(window.window_chapters) }
|
|
115
|
+
: null,
|
|
116
|
+
directives
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
inline.character_voice_drift_degraded = true;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const maybeAttachNarrativeHealthSummaries = async () => {
|
|
124
|
+
const engagementLatestAbs = join(args.rootDir, "logs/engagement/latest.json");
|
|
125
|
+
if (await pathExists(engagementLatestAbs)) {
|
|
126
|
+
const summary = await loadEngagementLatestSummary(args.rootDir);
|
|
127
|
+
if (summary)
|
|
128
|
+
inline.engagement_report_summary = summary;
|
|
129
|
+
else
|
|
130
|
+
inline.engagement_report_summary_degraded = true;
|
|
131
|
+
}
|
|
132
|
+
const promiseLatestAbs = join(args.rootDir, "logs/promises/latest.json");
|
|
133
|
+
if (await pathExists(promiseLatestAbs)) {
|
|
134
|
+
const summary = await loadPromiseLedgerLatestSummary(args.rootDir);
|
|
135
|
+
if (summary)
|
|
136
|
+
inline.promise_ledger_report_summary = summary;
|
|
137
|
+
else
|
|
138
|
+
inline.promise_ledger_report_summary_degraded = true;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
if (args.step.stage === "draft") {
|
|
142
|
+
agent = { kind: "subagent", name: "chapter-writer" };
|
|
143
|
+
// Optional: inject character voice drift directives (best-effort).
|
|
144
|
+
await maybeAttachCharacterVoiceDirectives();
|
|
145
|
+
// Optional: include narrative health source files (best-effort).
|
|
146
|
+
await maybeAddPath(paths, "promise_ledger", "promise-ledger.json");
|
|
147
|
+
await maybeAddPath(paths, "engagement_metrics", "engagement-metrics.jsonl");
|
|
148
|
+
await maybeAddPath(paths, "engagement_report_latest", "logs/engagement/latest.json");
|
|
149
|
+
await maybeAddPath(paths, "promise_ledger_report_latest", "logs/promises/latest.json");
|
|
150
|
+
// Optional: inject compact narrative health summaries (best-effort).
|
|
151
|
+
try {
|
|
152
|
+
await maybeAttachNarrativeHealthSummaries();
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
inline.engagement_report_summary_degraded = true;
|
|
156
|
+
inline.promise_ledger_report_summary_degraded = true;
|
|
157
|
+
}
|
|
158
|
+
// Optional: inject non-spoiler light-touch reminders for dormant foreshadowing items (best-effort).
|
|
159
|
+
try {
|
|
160
|
+
const loadedPlatform = await loadPlatformProfile(args.rootDir).catch(() => null);
|
|
161
|
+
const platform = loadedPlatform?.profile.platform ?? null;
|
|
162
|
+
const genreDriveType = typeof loadedPlatform?.profile.scoring?.genre_drive_type === "string" ? loadedPlatform.profile.scoring.genre_drive_type : null;
|
|
163
|
+
const items = await loadForeshadowGlobalItems(args.rootDir);
|
|
164
|
+
const report = computeForeshadowVisibilityReport({
|
|
165
|
+
items,
|
|
166
|
+
asOfChapter: args.step.chapter,
|
|
167
|
+
volume,
|
|
168
|
+
platform,
|
|
169
|
+
genreDriveType
|
|
170
|
+
});
|
|
171
|
+
const tasks = report.dormant_items.slice(0, 5).map((it) => ({
|
|
172
|
+
id: it.id,
|
|
173
|
+
scope: it.scope,
|
|
174
|
+
status: it.status,
|
|
175
|
+
chapters_since_last_update: it.chapters_since_last_update,
|
|
176
|
+
instruction: it.writing_task
|
|
177
|
+
}));
|
|
178
|
+
if (tasks.length > 0)
|
|
179
|
+
inline.foreshadow_light_touch_tasks = tasks;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
inline.foreshadow_light_touch_degraded = true;
|
|
183
|
+
}
|
|
184
|
+
expected_outputs.push({ path: rel.staging.chapterMd, required: true });
|
|
185
|
+
next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
|
|
186
|
+
next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
|
|
187
|
+
next_actions.push({
|
|
188
|
+
kind: "command",
|
|
189
|
+
command: `novel instructions chapter:${String(args.step.chapter).padStart(3, "0")}:summarize --json`,
|
|
190
|
+
note: "After advance, proceed to summarize."
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
else if (args.step.stage === "summarize") {
|
|
194
|
+
agent = { kind: "subagent", name: "summarizer" };
|
|
195
|
+
paths.chapter_draft = relIfExists(rel.staging.chapterMd, await pathExists(join(args.rootDir, rel.staging.chapterMd)));
|
|
196
|
+
expected_outputs.push({ path: rel.staging.summaryMd, required: true });
|
|
197
|
+
expected_outputs.push({ path: rel.staging.deltaJson, required: true });
|
|
198
|
+
expected_outputs.push({ path: rel.staging.crossrefJson, required: true });
|
|
199
|
+
expected_outputs.push({ path: "staging/storylines/{storyline_id}/memory.md", required: true, note: "storyline_id comes from delta.json" });
|
|
200
|
+
next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
|
|
201
|
+
next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
|
|
202
|
+
}
|
|
203
|
+
else if (args.step.stage === "refine") {
|
|
204
|
+
agent = { kind: "subagent", name: "style-refiner" };
|
|
205
|
+
// Optional: inject character voice drift directives (best-effort).
|
|
206
|
+
await maybeAttachCharacterVoiceDirectives();
|
|
207
|
+
// Optional: include narrative health source files (best-effort).
|
|
208
|
+
await maybeAddPath(paths, "promise_ledger", "promise-ledger.json");
|
|
209
|
+
await maybeAddPath(paths, "engagement_metrics", "engagement-metrics.jsonl");
|
|
210
|
+
await maybeAddPath(paths, "engagement_report_latest", "logs/engagement/latest.json");
|
|
211
|
+
await maybeAddPath(paths, "promise_ledger_report_latest", "logs/promises/latest.json");
|
|
212
|
+
// Optional: inject compact narrative health summaries (best-effort).
|
|
213
|
+
try {
|
|
214
|
+
await maybeAttachNarrativeHealthSummaries();
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
inline.engagement_report_summary_degraded = true;
|
|
218
|
+
inline.promise_ledger_report_summary_degraded = true;
|
|
219
|
+
}
|
|
220
|
+
paths.chapter_draft = relIfExists(rel.staging.chapterMd, await pathExists(join(args.rootDir, rel.staging.chapterMd)));
|
|
221
|
+
expected_outputs.push({ path: rel.staging.chapterMd, required: true });
|
|
222
|
+
expected_outputs.push({ path: rel.staging.styleRefinerChangesJson, required: false });
|
|
223
|
+
next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
|
|
224
|
+
next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
|
|
225
|
+
}
|
|
226
|
+
else if (args.step.stage === "judge") {
|
|
227
|
+
agent = { kind: "subagent", name: "quality-judge" };
|
|
228
|
+
const chapterDraftRel = relIfExists(rel.staging.chapterMd, await pathExists(join(args.rootDir, rel.staging.chapterMd)));
|
|
229
|
+
paths.chapter_draft = chapterDraftRel;
|
|
230
|
+
paths.cross_references = relIfExists(rel.staging.crossrefJson, await pathExists(join(args.rootDir, rel.staging.crossrefJson)));
|
|
231
|
+
const loadedPlatform = await loadPlatformProfile(args.rootDir);
|
|
232
|
+
if (loadedPlatform?.profile.scoring) {
|
|
233
|
+
const loadedWeights = await loadGenreWeightProfiles(args.rootDir);
|
|
234
|
+
if (!loadedWeights) {
|
|
235
|
+
throw new NovelCliError("Missing required file: genre-weight-profiles.json (required when platform-profile.json.scoring is present). Copy it from templates/genre-weight-profiles.json.", 2);
|
|
236
|
+
}
|
|
237
|
+
const effective = computeEffectiveScoringWeights({
|
|
238
|
+
config: loadedWeights.config,
|
|
239
|
+
scoring: loadedPlatform.profile.scoring,
|
|
240
|
+
hookPolicy: loadedPlatform.profile.hook_policy
|
|
241
|
+
});
|
|
242
|
+
inline.scoring_weights = {
|
|
243
|
+
...effective,
|
|
244
|
+
source: { platform_profile: loadedPlatform.relPath, genre_weight_profiles: loadedWeights.relPath }
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
// Optional: inject compact continuity summary for LS-001 evidence (non-blocking).
|
|
248
|
+
inline.continuity_report_summary = await loadContinuityLatestSummary(args.rootDir);
|
|
249
|
+
// Optional: pre-judge guardrails report (title/readability/naming). Non-blocking here; gate engine decides.
|
|
250
|
+
inline.prejudge_guardrails = null;
|
|
251
|
+
if (loadedPlatform && chapterDraftRel) {
|
|
252
|
+
try {
|
|
253
|
+
const report = await computePrejudgeGuardrailsReport({
|
|
254
|
+
rootDir: args.rootDir,
|
|
255
|
+
chapter: args.step.chapter,
|
|
256
|
+
chapterAbsPath: join(args.rootDir, chapterDraftRel),
|
|
257
|
+
platformProfileRelPath: loadedPlatform.relPath,
|
|
258
|
+
platformProfile: loadedPlatform.profile
|
|
259
|
+
});
|
|
260
|
+
const { relPath } = await writePrejudgeGuardrailsReport({ rootDir: args.rootDir, chapter: args.step.chapter, report });
|
|
261
|
+
paths.prejudge_guardrails = relPath;
|
|
262
|
+
inline.prejudge_guardrails = {
|
|
263
|
+
status: report.status,
|
|
264
|
+
has_blocking_issues: report.has_blocking_issues,
|
|
265
|
+
blocking_reasons: report.blocking_reasons,
|
|
266
|
+
report_path: relPath
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
inline.prejudge_guardrails_degraded = true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
expected_outputs.push({
|
|
274
|
+
path: rel.staging.evalJson,
|
|
275
|
+
required: true,
|
|
276
|
+
note: "QualityJudge returns JSON; the executor should write it to this path."
|
|
277
|
+
});
|
|
278
|
+
next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
|
|
279
|
+
next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
|
|
280
|
+
next_actions.push({
|
|
281
|
+
kind: "command",
|
|
282
|
+
command: `novel next`,
|
|
283
|
+
note: "After advance, compute the next deterministic step (may be title-fix/hook-fix/review/commit)."
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
else if (args.step.stage === "title-fix") {
|
|
287
|
+
agent = { kind: "subagent", name: "chapter-writer" };
|
|
288
|
+
paths.chapter_draft = relIfExists(rel.staging.chapterMd, await pathExists(join(args.rootDir, rel.staging.chapterMd)));
|
|
289
|
+
inline.fix_mode = "title-fix";
|
|
290
|
+
const loadedPlatform = await loadPlatformProfile(args.rootDir);
|
|
291
|
+
if (!loadedPlatform)
|
|
292
|
+
throw new NovelCliError("Missing required file: platform-profile.json (required for title-fix).", 2);
|
|
293
|
+
const titlePolicy = loadedPlatform.profile.retention?.title_policy ?? null;
|
|
294
|
+
if (!titlePolicy) {
|
|
295
|
+
throw new NovelCliError("platform-profile.json.retention.title_policy is required for title-fix.", 2);
|
|
296
|
+
}
|
|
297
|
+
// Snapshot the chapter before title-fix so validate can ensure body is byte-identical.
|
|
298
|
+
const beforeAbs = join(args.rootDir, rel.staging.chapterMd);
|
|
299
|
+
const before = await readTextFile(beforeAbs);
|
|
300
|
+
const snapshotRel = titleFixSnapshotRel(args.step.chapter);
|
|
301
|
+
await writeTextFileIfMissing(join(args.rootDir, snapshotRel), before);
|
|
302
|
+
paths.title_fix_before = snapshotRel;
|
|
303
|
+
const report = computeTitlePolicyReport({ chapter: args.step.chapter, chapterText: before, platformProfile: loadedPlatform.profile });
|
|
304
|
+
inline.title_policy = {
|
|
305
|
+
enabled: titlePolicy.enabled,
|
|
306
|
+
min_chars: titlePolicy.min_chars,
|
|
307
|
+
max_chars: titlePolicy.max_chars,
|
|
308
|
+
forbidden_patterns: titlePolicy.forbidden_patterns,
|
|
309
|
+
required_patterns: titlePolicy.required_patterns ?? null,
|
|
310
|
+
auto_fix: titlePolicy.auto_fix
|
|
311
|
+
};
|
|
312
|
+
inline.title_policy_report = {
|
|
313
|
+
status: report.status,
|
|
314
|
+
issues: report.issues.slice(0, 5)
|
|
315
|
+
};
|
|
316
|
+
inline.required_fixes = [
|
|
317
|
+
{
|
|
318
|
+
target: "chapter_title",
|
|
319
|
+
instruction: "执行 title-fix:只修改章节 Markdown 的标题行(第一个非空行必须是 H1:`# ...`)。禁止改动正文任何字符(CLI 会校验 body byte-identical)。标题需满足 platform-profile.json.retention.title_policy 的长度与正则规则,且不得包含 compliance.banned_words。避免剧透,保留悬念与吸引力。"
|
|
320
|
+
}
|
|
321
|
+
];
|
|
322
|
+
expected_outputs.push({ path: rel.staging.chapterMd, required: true, note: "Overwrite chapter draft with title-only fix (body must remain unchanged)." });
|
|
323
|
+
next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
|
|
324
|
+
next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
|
|
325
|
+
next_actions.push({
|
|
326
|
+
kind: "command",
|
|
327
|
+
command: `novel next`,
|
|
328
|
+
note: "After title-fix, compute the next deterministic step (typically judge)."
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
else if (args.step.stage === "hook-fix") {
|
|
332
|
+
agent = { kind: "subagent", name: "chapter-writer" };
|
|
333
|
+
paths.chapter_draft = relIfExists(rel.staging.chapterMd, await pathExists(join(args.rootDir, rel.staging.chapterMd)));
|
|
334
|
+
paths.chapter_eval = relIfExists(rel.staging.evalJson, await pathExists(join(args.rootDir, rel.staging.evalJson)));
|
|
335
|
+
inline.fix_mode = "hook-fix";
|
|
336
|
+
inline.required_fixes = [
|
|
337
|
+
{
|
|
338
|
+
target: "chapter_end",
|
|
339
|
+
instruction: "执行 hook-fix:在不改变前文既定事件/信息的前提下,只改最后 1–2 段(或末尾 ~10%),补强章末钩子(读者面对面悬念/威胁/反转/情绪 cliff/下一目标承诺)。钩子类型需遵守 platform-profile.json.hook_policy.allowed_types,目标 hook_strength >= platform-profile.json.hook_policy.min_strength。禁止新增关键设定/新命名角色/新地点,尽量不影响 state/crossref。",
|
|
340
|
+
}
|
|
341
|
+
];
|
|
342
|
+
expected_outputs.push({ path: rel.staging.chapterMd, required: true, note: "Overwrite chapter draft with ending-only hook fix." });
|
|
343
|
+
next_actions.push({ kind: "command", command: `novel validate ${stepId}` });
|
|
344
|
+
next_actions.push({ kind: "command", command: `novel advance ${stepId}` });
|
|
345
|
+
next_actions.push({
|
|
346
|
+
kind: "command",
|
|
347
|
+
command: `novel instructions chapter:${String(args.step.chapter).padStart(3, "0")}:judge --json`,
|
|
348
|
+
note: "After hook-fix, re-run QualityJudge to refresh eval."
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
else if (args.step.stage === "review") {
|
|
352
|
+
agent = { kind: "cli", name: "manual-review" };
|
|
353
|
+
paths.chapter_draft = relIfExists(rel.staging.chapterMd, await pathExists(join(args.rootDir, rel.staging.chapterMd)));
|
|
354
|
+
paths.chapter_eval = relIfExists(rel.staging.evalJson, await pathExists(join(args.rootDir, rel.staging.evalJson)));
|
|
355
|
+
expected_outputs.push({ path: "(manual)", required: false, note: "Review required: guardrails still failing after bounded auto-fix." });
|
|
356
|
+
next_actions.push({
|
|
357
|
+
kind: "command",
|
|
358
|
+
command: `novel instructions chapter:${String(args.step.chapter).padStart(3, "0")}:judge --json`,
|
|
359
|
+
note: "After manually fixing the chapter (title/hook/etc), re-run QualityJudge."
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
else if (args.step.stage === "commit") {
|
|
363
|
+
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}` });
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
throw new NovelCliError(`Unsupported step stage: ${args.step.stage}`, 2);
|
|
369
|
+
}
|
|
370
|
+
const gate = args.novelAskGate ?? null;
|
|
371
|
+
const gateSpec = gate ? parseNovelAskQuestionSpec(gate.novel_ask) : null;
|
|
372
|
+
if (gate) {
|
|
373
|
+
resolveProjectRelativePath(args.rootDir, gate.answer_path, "novelAskGate.answer_path");
|
|
374
|
+
expected_outputs.unshift({
|
|
375
|
+
path: gate.answer_path,
|
|
376
|
+
required: true,
|
|
377
|
+
note: "AnswerSpec JSON record for the NOVEL_ASK gate (written before main step execution)."
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
const packet = {
|
|
381
|
+
version: 1,
|
|
382
|
+
step: stepId,
|
|
383
|
+
agent,
|
|
384
|
+
...(gate ? { novel_ask: gateSpec, answer_path: gate.answer_path } : {}),
|
|
385
|
+
manifest: {
|
|
386
|
+
mode: embedMode === "off" ? "paths" : "paths+embed",
|
|
387
|
+
inline,
|
|
388
|
+
paths,
|
|
389
|
+
...(embedMode === "off" ? {} : { embed })
|
|
390
|
+
},
|
|
391
|
+
expected_outputs,
|
|
392
|
+
next_actions
|
|
393
|
+
};
|
|
394
|
+
let writtenPath = null;
|
|
395
|
+
if (args.writeManifest) {
|
|
396
|
+
const manifestsDir = join(args.rootDir, "staging/manifests");
|
|
397
|
+
await ensureDir(manifestsDir);
|
|
398
|
+
const fileName = `${stepId.replaceAll(":", "-")}.packet.json`;
|
|
399
|
+
writtenPath = `staging/manifests/${fileName}`;
|
|
400
|
+
await writeJsonFile(join(args.rootDir, writtenPath), packet);
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
packet,
|
|
404
|
+
...(writtenPath ? { written_manifest_path: writtenPath } : {})
|
|
405
|
+
};
|
|
406
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { pathExists, readJsonFile } from "./fs-utils.js";
|
|
4
|
+
import { assertInsideProjectRoot } from "./safe-path.js";
|
|
5
|
+
export const MAX_LATEST_JSON_BYTES = 512 * 1024;
|
|
6
|
+
export async function loadLatestJsonSummary(args) {
|
|
7
|
+
const abs = join(args.rootDir, args.relPath);
|
|
8
|
+
if (!(await pathExists(abs)))
|
|
9
|
+
return null;
|
|
10
|
+
try {
|
|
11
|
+
const rootReal = await realpath(args.rootDir);
|
|
12
|
+
const absReal = await realpath(abs);
|
|
13
|
+
// Ensure the resolved path stays under the project root (defense against symlink escapes).
|
|
14
|
+
assertInsideProjectRoot(rootReal, absReal);
|
|
15
|
+
// Also guard against pathological cases where realpath changes drive letters/roots.
|
|
16
|
+
if (relative(rootReal, absReal).startsWith(".."))
|
|
17
|
+
return null;
|
|
18
|
+
const st = await stat(absReal);
|
|
19
|
+
if (!st.isFile())
|
|
20
|
+
return null;
|
|
21
|
+
if (st.size > (args.maxBytes ?? MAX_LATEST_JSON_BYTES))
|
|
22
|
+
return null;
|
|
23
|
+
const raw = await readJsonFile(absReal);
|
|
24
|
+
return args.summarize(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|