novel-writer-cli 0.1.0 → 0.2.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/dist/__tests__/checkpoint-quickstart-phase.test.js +49 -0
- package/dist/__tests__/cli-instructions-novel-ask-gate.test.js +83 -0
- package/dist/__tests__/cli-repair-reset-quickstart.test.js +194 -0
- package/dist/__tests__/cli-version-flag.test.js +38 -0
- package/dist/__tests__/init.test.js +9 -6
- package/dist/__tests__/instructions-review-novel-ask-gate.test.js +31 -0
- package/dist/__tests__/orchestrator-state-routing.test.js +10 -6
- package/dist/__tests__/quickstart-pipeline.test.js +346 -0
- package/dist/__tests__/safe-path-symlink.test.js +41 -0
- package/dist/__tests__/validate-quickstart-prereqs.test.js +73 -0
- package/dist/advance.js +88 -3
- package/dist/checkpoint.js +25 -4
- package/dist/cli.js +130 -4
- package/dist/init.js +2 -1
- package/dist/instructions.js +162 -1
- package/dist/next-step.js +227 -4
- package/dist/quickstart-validators.js +84 -0
- package/dist/quickstart.js +16 -0
- package/dist/safe-path.js +23 -1
- package/dist/validate.js +72 -1
- package/docs/user/README.md +0 -1
- package/package.json +1 -1
- package/scripts/sync-final-spec-skills.mjs +65 -0
- package/skills/cli-step/SKILL.md +186 -32
- package/skills/continue/SKILL.md +30 -326
- package/skills/shared/thin-adapter-loop.md +67 -0
- package/skills/start/SKILL.md +23 -440
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { NovelCliError } from "./errors.js";
|
|
4
|
+
import { readJsonFile, readTextFile } from "./fs-utils.js";
|
|
5
|
+
import { QUICKSTART_STAGING_RELS } from "./quickstart.js";
|
|
6
|
+
import { isPlainObject } from "./type-guards.js";
|
|
7
|
+
function requireStringField(obj, field, file, opts) {
|
|
8
|
+
const v = obj[field];
|
|
9
|
+
if (typeof v !== "string" || (opts?.trim ? v.trim().length === 0 : v.length === 0)) {
|
|
10
|
+
throw new NovelCliError(`Invalid ${file}: missing string field '${field}'.`, 2);
|
|
11
|
+
}
|
|
12
|
+
return v;
|
|
13
|
+
}
|
|
14
|
+
export async function validateQuickstartRulesSchema(absPath, options) {
|
|
15
|
+
const raw = await readJsonFile(absPath);
|
|
16
|
+
if (!isPlainObject(raw))
|
|
17
|
+
throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.rulesJson}: expected JSON object.`, 2);
|
|
18
|
+
const obj = raw;
|
|
19
|
+
const rules = obj.rules;
|
|
20
|
+
if (!Array.isArray(rules))
|
|
21
|
+
throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.rulesJson}: missing 'rules' array.`, 2);
|
|
22
|
+
const trimRequiredStrings = options?.trimRequiredStrings === true;
|
|
23
|
+
for (const [idx, rule] of rules.entries()) {
|
|
24
|
+
if (!isPlainObject(rule))
|
|
25
|
+
throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.rulesJson}: rules[${idx}] must be an object.`, 2);
|
|
26
|
+
const r = rule;
|
|
27
|
+
requireStringField(r, "id", QUICKSTART_STAGING_RELS.rulesJson, { trim: trimRequiredStrings });
|
|
28
|
+
requireStringField(r, "category", QUICKSTART_STAGING_RELS.rulesJson, { trim: trimRequiredStrings });
|
|
29
|
+
requireStringField(r, "rule", QUICKSTART_STAGING_RELS.rulesJson, { trim: trimRequiredStrings });
|
|
30
|
+
const ct = requireStringField(r, "constraint_type", QUICKSTART_STAGING_RELS.rulesJson, { trim: false });
|
|
31
|
+
if (ct !== "hard" && ct !== "soft") {
|
|
32
|
+
throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.rulesJson}: rules[${idx}].constraint_type must be hard|soft.`, 2);
|
|
33
|
+
}
|
|
34
|
+
if (!Array.isArray(r.exceptions)) {
|
|
35
|
+
throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.rulesJson}: rules[${idx}].exceptions must be an array.`, 2);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return rules.length;
|
|
39
|
+
}
|
|
40
|
+
export async function listQuickstartContractJsonFiles(absContractsDir) {
|
|
41
|
+
const entries = await readdir(absContractsDir, { withFileTypes: true });
|
|
42
|
+
const jsonFiles = entries
|
|
43
|
+
.filter((e) => e.isFile() && e.name.endsWith(".json"))
|
|
44
|
+
.map((e) => e.name)
|
|
45
|
+
.sort();
|
|
46
|
+
if (jsonFiles.length === 0) {
|
|
47
|
+
throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.contractsDir}: expected at least 1 *.json contract file.`, 2);
|
|
48
|
+
}
|
|
49
|
+
return jsonFiles;
|
|
50
|
+
}
|
|
51
|
+
export async function validateQuickstartContractJsonFiles(absContractsDir, jsonFiles) {
|
|
52
|
+
for (const file of jsonFiles) {
|
|
53
|
+
const raw = await readJsonFile(join(absContractsDir, file));
|
|
54
|
+
if (!isPlainObject(raw)) {
|
|
55
|
+
throw new NovelCliError(`Invalid contract JSON: ${QUICKSTART_STAGING_RELS.contractsDir}/${file} must be an object.`, 2);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export async function validateQuickstartContractsDir(absContractsDir) {
|
|
60
|
+
const jsonFiles = await listQuickstartContractJsonFiles(absContractsDir);
|
|
61
|
+
await validateQuickstartContractJsonFiles(absContractsDir, jsonFiles);
|
|
62
|
+
}
|
|
63
|
+
export async function validateQuickstartStyleProfileSchema(absPath) {
|
|
64
|
+
const raw = await readJsonFile(absPath);
|
|
65
|
+
if (!isPlainObject(raw))
|
|
66
|
+
throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.styleProfileJson}: expected JSON object.`, 2);
|
|
67
|
+
const obj = raw;
|
|
68
|
+
const sourceType = obj.source_type;
|
|
69
|
+
if (typeof sourceType !== "string" || sourceType.trim().length === 0) {
|
|
70
|
+
throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.styleProfileJson}: source_type must be a non-empty string.`, 2);
|
|
71
|
+
}
|
|
72
|
+
if (sourceType !== "original" && sourceType !== "reference" && sourceType !== "template" && sourceType !== "write_then_extract") {
|
|
73
|
+
throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.styleProfileJson}: source_type must be one of: original, reference, template, write_then_extract.`, 2);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export async function validateQuickstartTrialChapter(absPath) {
|
|
77
|
+
const text = await readTextFile(absPath);
|
|
78
|
+
if (text.trim().length === 0)
|
|
79
|
+
throw new NovelCliError(`Empty draft file: ${QUICKSTART_STAGING_RELS.trialChapterMd}`, 2);
|
|
80
|
+
if (!text.trimStart().startsWith("#")) {
|
|
81
|
+
return `Trial chapter does not start with a Markdown H1 (# ...): ${QUICKSTART_STAGING_RELS.trialChapterMd}`;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const QUICKSTART_STAGING_RELS = {
|
|
2
|
+
dir: "staging/quickstart",
|
|
3
|
+
rulesJson: "staging/quickstart/rules.json",
|
|
4
|
+
contractsDir: "staging/quickstart/contracts",
|
|
5
|
+
styleProfileJson: "staging/quickstart/style-profile.json",
|
|
6
|
+
trialChapterMd: "staging/quickstart/trial-chapter.md",
|
|
7
|
+
evaluationJson: "staging/quickstart/evaluation.json"
|
|
8
|
+
};
|
|
9
|
+
export const QUICKSTART_FINAL_RELS = {
|
|
10
|
+
worldRulesJson: "world/rules.json",
|
|
11
|
+
charactersActiveDir: "characters/active",
|
|
12
|
+
styleProfileJson: "style-profile.json",
|
|
13
|
+
logsDir: "logs/quickstart",
|
|
14
|
+
trialChapterMd: "logs/quickstart/trial-chapter.md",
|
|
15
|
+
evaluationJson: "logs/quickstart/evaluation.json"
|
|
16
|
+
};
|
package/dist/safe-path.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, sep } from "node:path";
|
|
2
3
|
import { NovelCliError } from "./errors.js";
|
|
3
4
|
export function rejectPathTraversalInput(inputPath, label) {
|
|
4
5
|
const normalized = inputPath.replaceAll("\\", "/");
|
|
@@ -25,5 +26,26 @@ export function resolveProjectRelativePath(projectRootAbs, relPath, label) {
|
|
|
25
26
|
rejectPathTraversalInput(relPath, label);
|
|
26
27
|
const abs = join(projectRootAbs, relPath);
|
|
27
28
|
assertInsideProjectRoot(projectRootAbs, abs);
|
|
29
|
+
// Symlink-aware containment check: prevent resolving to a path outside the project root.
|
|
30
|
+
// - If the target exists, validate its realpath.
|
|
31
|
+
// - If the target doesn't exist yet (write target), validate the nearest existing ancestor dir realpath.
|
|
32
|
+
const rootReal = realpathSync(projectRootAbs);
|
|
33
|
+
if (existsSync(abs)) {
|
|
34
|
+
const realAbs = realpathSync(abs);
|
|
35
|
+
assertInsideProjectRoot(rootReal, realAbs);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
let probe = dirname(abs);
|
|
39
|
+
while (probe !== projectRootAbs && !existsSync(probe)) {
|
|
40
|
+
const parent = dirname(probe);
|
|
41
|
+
if (parent === probe)
|
|
42
|
+
break;
|
|
43
|
+
probe = parent;
|
|
44
|
+
}
|
|
45
|
+
if (existsSync(probe)) {
|
|
46
|
+
const realProbe = realpathSync(probe);
|
|
47
|
+
assertInsideProjectRoot(rootReal, realProbe);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
28
50
|
return abs;
|
|
29
51
|
}
|
package/dist/validate.js
CHANGED
|
@@ -3,12 +3,14 @@ import { NovelCliError } from "./errors.js";
|
|
|
3
3
|
import { pathExists, readJsonFile, readTextFile } from "./fs-utils.js";
|
|
4
4
|
import { checkHookPolicy } from "./hook-policy.js";
|
|
5
5
|
import { loadPlatformProfile } from "./platform-profile.js";
|
|
6
|
+
import { QUICKSTART_STAGING_RELS } from "./quickstart.js";
|
|
6
7
|
import { rejectPathTraversalInput } from "./safe-path.js";
|
|
7
|
-
import { chapterRelPaths, formatStepId, titleFixSnapshotRel } from "./steps.js";
|
|
8
|
+
import { QUICKSTART_PHASES, chapterRelPaths, formatStepId, titleFixSnapshotRel } from "./steps.js";
|
|
8
9
|
import { assertTitleFixOnlyChangedTitleLine, extractChapterTitleFromMarkdown } from "./title-policy.js";
|
|
9
10
|
import { isPlainObject } from "./type-guards.js";
|
|
10
11
|
import { VOL_REVIEW_RELS } from "./volume-review.js";
|
|
11
12
|
import { computeVolumeChapterRange, volumeFinalRelPaths, volumeForChapter, volumeStagingRelPaths } from "./volume-planning.js";
|
|
13
|
+
import { validateQuickstartContractsDir, validateQuickstartRulesSchema, validateQuickstartStyleProfileSchema, validateQuickstartTrialChapter } from "./quickstart-validators.js";
|
|
12
14
|
function requireFile(exists, relPath) {
|
|
13
15
|
if (!exists)
|
|
14
16
|
throw new NovelCliError(`Missing required file: ${relPath}`, 2);
|
|
@@ -300,6 +302,75 @@ export async function validateStep(args) {
|
|
|
300
302
|
await validateContracts(storylinesByChapter);
|
|
301
303
|
return { ok: true, step: stepId, warnings };
|
|
302
304
|
}
|
|
305
|
+
if (args.step.kind === "quickstart") {
|
|
306
|
+
const rulesAbs = join(args.rootDir, QUICKSTART_STAGING_RELS.rulesJson);
|
|
307
|
+
const contractsAbs = join(args.rootDir, QUICKSTART_STAGING_RELS.contractsDir);
|
|
308
|
+
const styleAbs = join(args.rootDir, QUICKSTART_STAGING_RELS.styleProfileJson);
|
|
309
|
+
const trialAbs = join(args.rootDir, QUICKSTART_STAGING_RELS.trialChapterMd);
|
|
310
|
+
const evalAbs = join(args.rootDir, QUICKSTART_STAGING_RELS.evaluationJson);
|
|
311
|
+
if (args.step.phase === "results") {
|
|
312
|
+
requireFile(await pathExists(rulesAbs), QUICKSTART_STAGING_RELS.rulesJson);
|
|
313
|
+
requireFile(await pathExists(contractsAbs), QUICKSTART_STAGING_RELS.contractsDir);
|
|
314
|
+
requireFile(await pathExists(styleAbs), QUICKSTART_STAGING_RELS.styleProfileJson);
|
|
315
|
+
requireFile(await pathExists(trialAbs), QUICKSTART_STAGING_RELS.trialChapterMd);
|
|
316
|
+
requireFile(await pathExists(evalAbs), QUICKSTART_STAGING_RELS.evaluationJson);
|
|
317
|
+
// Re-validate the whole quickstart staging set before committing to final dirs.
|
|
318
|
+
const rulesCount = await validateQuickstartRulesSchema(rulesAbs);
|
|
319
|
+
if (rulesCount === 0)
|
|
320
|
+
warnings.push(`Empty rules list in ${QUICKSTART_STAGING_RELS.rulesJson}.`);
|
|
321
|
+
await validateQuickstartContractsDir(contractsAbs);
|
|
322
|
+
await validateQuickstartStyleProfileSchema(styleAbs);
|
|
323
|
+
const warning = await validateQuickstartTrialChapter(trialAbs);
|
|
324
|
+
if (warning)
|
|
325
|
+
warnings.push(warning);
|
|
326
|
+
const evalRaw = await readJsonFile(evalAbs);
|
|
327
|
+
if (!isPlainObject(evalRaw))
|
|
328
|
+
throw new NovelCliError(`Invalid ${QUICKSTART_STAGING_RELS.evaluationJson}: expected JSON object.`, 2);
|
|
329
|
+
const evalObj = evalRaw;
|
|
330
|
+
if (typeof evalObj.overall !== "number")
|
|
331
|
+
warnings.push(`Missing numeric field 'overall' in ${QUICKSTART_STAGING_RELS.evaluationJson}.`);
|
|
332
|
+
if (typeof evalObj.recommendation !== "string")
|
|
333
|
+
warnings.push(`Missing string field 'recommendation' in ${QUICKSTART_STAGING_RELS.evaluationJson}.`);
|
|
334
|
+
return { ok: true, step: stepId, warnings };
|
|
335
|
+
}
|
|
336
|
+
const phaseIdx = QUICKSTART_PHASES.indexOf(args.step.phase);
|
|
337
|
+
if (phaseIdx < 0) {
|
|
338
|
+
throw new NovelCliError(`Unsupported quickstart phase: ${String(args.step.phase)}`, 2);
|
|
339
|
+
}
|
|
340
|
+
for (const phase of QUICKSTART_PHASES.slice(0, phaseIdx + 1)) {
|
|
341
|
+
switch (phase) {
|
|
342
|
+
case "world": {
|
|
343
|
+
requireFile(await pathExists(rulesAbs), QUICKSTART_STAGING_RELS.rulesJson);
|
|
344
|
+
const rulesCount = await validateQuickstartRulesSchema(rulesAbs);
|
|
345
|
+
if (rulesCount === 0)
|
|
346
|
+
warnings.push(`Empty rules list in ${QUICKSTART_STAGING_RELS.rulesJson}.`);
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
case "characters":
|
|
350
|
+
requireFile(await pathExists(contractsAbs), QUICKSTART_STAGING_RELS.contractsDir);
|
|
351
|
+
await validateQuickstartContractsDir(contractsAbs);
|
|
352
|
+
break;
|
|
353
|
+
case "style":
|
|
354
|
+
requireFile(await pathExists(styleAbs), QUICKSTART_STAGING_RELS.styleProfileJson);
|
|
355
|
+
await validateQuickstartStyleProfileSchema(styleAbs);
|
|
356
|
+
break;
|
|
357
|
+
case "trial": {
|
|
358
|
+
requireFile(await pathExists(trialAbs), QUICKSTART_STAGING_RELS.trialChapterMd);
|
|
359
|
+
const warning = await validateQuickstartTrialChapter(trialAbs);
|
|
360
|
+
if (warning)
|
|
361
|
+
warnings.push(warning);
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
case "results":
|
|
365
|
+
break;
|
|
366
|
+
default: {
|
|
367
|
+
const _exhaustive = phase;
|
|
368
|
+
throw new NovelCliError(`Unsupported quickstart phase: ${String(_exhaustive)}`, 2);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return { ok: true, step: stepId, warnings };
|
|
373
|
+
}
|
|
303
374
|
if (args.step.kind !== "chapter")
|
|
304
375
|
throw new NovelCliError(`Unsupported step: ${stepId}`, 2);
|
|
305
376
|
const rel = chapterRelPaths(args.step.chapter);
|
package/docs/user/README.md
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { dirname, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
6
|
+
|
|
7
|
+
const inputs = {
|
|
8
|
+
start: "skills/start/SKILL.md",
|
|
9
|
+
continue: "skills/continue/SKILL.md",
|
|
10
|
+
status: "skills/status/SKILL.md",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const outputPath = "docs/dr-workflow/novel-writer-tool/final/spec/02-skills.md";
|
|
14
|
+
|
|
15
|
+
async function readUtf8(relPath) {
|
|
16
|
+
const absPath = resolve(repoRoot, relPath);
|
|
17
|
+
const text = await fs.readFile(absPath, "utf8");
|
|
18
|
+
return text.endsWith("\n") ? text : `${text}\n`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const [startSkill, continueSkill, statusSkill] = await Promise.all([
|
|
22
|
+
readUtf8(inputs.start),
|
|
23
|
+
readUtf8(inputs.continue),
|
|
24
|
+
readUtf8(inputs.status),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const out = [
|
|
28
|
+
"## 3. 入口 Skills",
|
|
29
|
+
"",
|
|
30
|
+
`> 说明:本页为入口 skill 文档的快照(便于 Tech Spec 自包含)。canonical 以 \`skills/**/SKILL.md\` 为准;修改 skill 后需同步更新此处(可用 \`node scripts/sync-final-spec-skills.mjs\` 生成)。`,
|
|
31
|
+
"",
|
|
32
|
+
"### 3.1 `/novel:start` — 启动适配层(Thin Adapter)",
|
|
33
|
+
"",
|
|
34
|
+
"## 文件路径:`skills/start/SKILL.md`",
|
|
35
|
+
"",
|
|
36
|
+
"````markdown",
|
|
37
|
+
startSkill.trimEnd(),
|
|
38
|
+
"````",
|
|
39
|
+
"",
|
|
40
|
+
"---",
|
|
41
|
+
"",
|
|
42
|
+
"### 3.2 `/novel:continue` — 续写适配层(Thin Adapter)",
|
|
43
|
+
"",
|
|
44
|
+
"## 文件路径:`skills/continue/SKILL.md`",
|
|
45
|
+
"",
|
|
46
|
+
"````markdown",
|
|
47
|
+
continueSkill.trimEnd(),
|
|
48
|
+
"````",
|
|
49
|
+
"",
|
|
50
|
+
"---",
|
|
51
|
+
"",
|
|
52
|
+
"### 3.3 `/novel:status` — 只读状态展示",
|
|
53
|
+
"",
|
|
54
|
+
"## 文件路径:`skills/status/SKILL.md`",
|
|
55
|
+
"",
|
|
56
|
+
"````markdown",
|
|
57
|
+
statusSkill.trimEnd(),
|
|
58
|
+
"````",
|
|
59
|
+
"",
|
|
60
|
+
"---",
|
|
61
|
+
"",
|
|
62
|
+
].join("\n");
|
|
63
|
+
|
|
64
|
+
await fs.writeFile(resolve(repoRoot, outputPath), `${out}`, "utf8");
|
|
65
|
+
console.error(`Wrote ${relative(repoRoot, resolve(repoRoot, outputPath))}`);
|
package/skills/cli-step/SKILL.md
CHANGED
|
@@ -1,30 +1,124 @@
|
|
|
1
1
|
# `novel` CLI 单步适配器(Claude Code)
|
|
2
2
|
|
|
3
|
-
你是 Claude Code 的执行器适配层:你不做确定性编排逻辑,只调用 `novel` CLI 获取 step + instruction packet
|
|
3
|
+
你是 Claude Code 的执行器适配层:你不做确定性编排逻辑,只调用 `novel` CLI 获取 step + instruction packet,然后按 packet 指定的 agent 执行(subagent 或 CLI actions),再执行 validate → advance(若适用),最后在断点处停下让用户 review。
|
|
4
4
|
|
|
5
5
|
## 运行约束
|
|
6
6
|
|
|
7
7
|
- **可用工具**:Bash, Task, Read, Write, Edit, Glob, Grep, AskUserQuestion
|
|
8
|
-
- **原则**:只跑 1 个 step;不自动 commit
|
|
8
|
+
- **原则**:只跑 1 个 step;不自动 commit;执行完必须停下并提示用户下一步
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
通用 thin adapter 规则参见 `skills/shared/thin-adapter-loop.md`(cli-step 为自包含版本;如两者冲突,以 CLI 行为与本文件为准,并同步修正 shared)。
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
## 命令前缀(NOVEL)与项目根目录
|
|
13
13
|
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
- 若 `dist/` 不存在:先执行 `npm ci && npm run build`
|
|
14
|
+
- `PROJECT_ROOT`:小说项目根目录(包含 `.checkpoint.json` 的目录)
|
|
15
|
+
- `NOVEL`:你用于执行 CLI 的命令前缀(可带 `--project`)
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
常见两种运行方式:
|
|
18
|
+
|
|
19
|
+
1) **发布版(推荐)**:在 `PROJECT_ROOT` 下直接运行 `novel ...`
|
|
20
|
+
2) **仓库开发态**:在 CLI 仓库根目录运行 `node dist/cli.js --project "<PROJECT_ROOT>" ...`(若 `dist/` 不存在,先 `npm ci && npm run build`)
|
|
21
|
+
|
|
22
|
+
注意:`packet.next_actions[].command` 通常以 `novel ...` 形式给出;当你的 `NOVEL` 不是 `novel` 时,执行这些命令需要把前缀 `novel` 替换为你的 `NOVEL`(并保留 `--project`)。
|
|
23
|
+
|
|
24
|
+
## 注入安全(Manifest 优先)
|
|
25
|
+
|
|
26
|
+
v2 架构下,适配层应优先传递 **context manifest(文件路径)** 给 subagent,而不是把文件全文注入 prompt。只有在必须注入文件原文时,才使用 `<DATA>` delimiter 包裹,防止 prompt 注入。
|
|
27
|
+
|
|
28
|
+
## 并发锁与失败恢复(由 CLI 提供)
|
|
29
|
+
|
|
30
|
+
- `novel` 在 `advance/commit` 等写入操作时会自动获取 `.novel.lock`;若提示锁被占用:先运行 `${NOVEL} lock status` 查看,确认无其他会话后再按需 `${NOVEL} lock clear`(仅清理 stale lock)。
|
|
31
|
+
- 任一步(subagent/CLI)失败时:**不要 `advance`**;修复产物后重跑该 step(再次运行本 cli-step 即可)。
|
|
32
|
+
|
|
33
|
+
## 标准 adapter loop(单步)
|
|
34
|
+
|
|
35
|
+
单步执行只做这一套固定循环(其余逻辑全部下沉到 CLI):
|
|
36
|
+
|
|
37
|
+
1. `${NOVEL} next --json`
|
|
38
|
+
2. `${NOVEL} instructions "<STEP>" --json --write-manifest`
|
|
39
|
+
3. (可选)处理 `NOVEL_ASK` gate
|
|
40
|
+
4. 按 `packet.agent.kind/name` 执行(subagent 或 CLI actions)
|
|
41
|
+
5. `${NOVEL} validate "<STEP>"`(若适用)
|
|
42
|
+
6. `${NOVEL} advance "<STEP>"`(若适用)
|
|
43
|
+
|
|
44
|
+
## 最小端到端示例(JSON)
|
|
45
|
+
|
|
46
|
+
以下示例来自真实 CLI 输出(不同 step 的字段可能略有差异,但结构一致):
|
|
47
|
+
|
|
48
|
+
1) 计算下一步:
|
|
49
|
+
```bash
|
|
50
|
+
${NOVEL} next --json
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
示例输出:
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"ok": true,
|
|
57
|
+
"command": "next",
|
|
58
|
+
"data": {
|
|
59
|
+
"rootDir": "<PROJECT_ROOT>",
|
|
60
|
+
"step": "chapter:002:draft",
|
|
61
|
+
"reason": "fresh",
|
|
62
|
+
"inflight": { "chapter": null, "pipeline_stage": "committed" }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
2) 生成 instruction packet(并落盘 manifest):
|
|
68
|
+
```bash
|
|
69
|
+
${NOVEL} instructions "chapter:002:draft" --json --write-manifest
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
示例输出(截取关键字段):
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"ok": true,
|
|
76
|
+
"command": "instructions",
|
|
77
|
+
"data": {
|
|
78
|
+
"packet": {
|
|
79
|
+
"version": 1,
|
|
80
|
+
"step": "chapter:002:draft",
|
|
81
|
+
"agent": { "kind": "subagent", "name": "chapter-writer" },
|
|
82
|
+
"expected_outputs": [{ "path": "staging/chapters/chapter-002.md", "required": true }],
|
|
83
|
+
"next_actions": [
|
|
84
|
+
{ "kind": "command", "command": "novel validate chapter:002:draft" },
|
|
85
|
+
{ "kind": "command", "command": "novel advance chapter:002:draft" },
|
|
86
|
+
{
|
|
87
|
+
"kind": "command",
|
|
88
|
+
"command": "novel instructions chapter:002:summarize --json",
|
|
89
|
+
"note": "After advance, proceed to summarize."
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
},
|
|
93
|
+
"written_manifest_path": "staging/manifests/chapter-002-draft.packet.json"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
19
97
|
|
|
20
|
-
|
|
98
|
+
3) 派发 subagent 写入 `expected_outputs[]` 后,按 `next_actions[]` 执行 validate/advance:
|
|
21
99
|
```bash
|
|
22
|
-
|
|
100
|
+
${NOVEL} validate chapter:002:draft
|
|
101
|
+
${NOVEL} advance chapter:002:draft
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
validate 失败示例(exit != 0 时必须停止,不得 advance):
|
|
105
|
+
```json
|
|
106
|
+
{ "ok": false, "command": "validate", "error": { "message": "Missing required file: staging/chapters/chapter-002.md" } }
|
|
23
107
|
```
|
|
24
108
|
|
|
25
|
-
|
|
109
|
+
## 执行流程
|
|
110
|
+
|
|
111
|
+
### Step 0: 前置检查
|
|
112
|
+
|
|
113
|
+
- 确认 `PROJECT_ROOT` 存在且包含 `.checkpoint.json`
|
|
114
|
+
- 若当前不在 `PROJECT_ROOT`:建议先 `cd` 到 `PROJECT_ROOT`(因为 packet 的路径通常是 project-relative;subagent 需要在项目根目录下读写 `staging/**`)
|
|
115
|
+
- 若你使用的是仓库开发态(`node dist/cli.js ...`):确保 `dist/` 已构建(`npm ci && npm run build` 在 CLI 仓库根目录执行)
|
|
116
|
+
|
|
117
|
+
### Step 1: 计算下一步 step id
|
|
118
|
+
|
|
119
|
+
使用 `${NOVEL}`:
|
|
26
120
|
```bash
|
|
27
|
-
|
|
121
|
+
${NOVEL} next --json
|
|
28
122
|
```
|
|
29
123
|
|
|
30
124
|
解析 stdout 的单对象 JSON:取 `data.step` 得到类似 `chapter:048:draft` 的 step id。
|
|
@@ -32,7 +126,7 @@ node dist/cli.js next --json
|
|
|
32
126
|
### Step 2: 生成 instruction packet(并落盘 manifest)
|
|
33
127
|
|
|
34
128
|
```bash
|
|
35
|
-
|
|
129
|
+
${NOVEL} instructions "<STEP_ID>" --json --write-manifest
|
|
36
130
|
```
|
|
37
131
|
|
|
38
132
|
同样解析 stdout JSON:取 `data.packet`(以及可选的 `data.written_manifest_path`)。
|
|
@@ -46,20 +140,43 @@ novel instructions "<STEP_ID>" --json --write-manifest
|
|
|
46
140
|
|
|
47
141
|
则在派发 subagent 前必须先满足 gate:收集回答 → 写入 AnswerSpec → 校验通过后才继续。
|
|
48
142
|
|
|
143
|
+
> 维护说明:`skills/start` / `skills/continue` 通过 `skills/shared/thin-adapter-loop.md` 复用本段 gate 语义;修改本段时请同步检查 shared 与入口 skills 的一致性。
|
|
144
|
+
|
|
49
145
|
#### Step 3.1: 检查是否已存在可用 AnswerSpec(可恢复语义)
|
|
50
146
|
|
|
51
147
|
若 `answer_path` 已存在且通过校验:直接进入 Step 4。
|
|
52
148
|
|
|
149
|
+
> 下面两段 gate 校验/落盘脚本依赖 `./dist/*`(CLI build outputs),更适合在**仓库开发态**执行:在 CLI 仓库根目录运行脚本,并将 `ROOT_DIR` 指向小说项目根目录。
|
|
150
|
+
|
|
53
151
|
校验命令(会做 questionSpec↔answerSpec cross-validate;缺失则 exit 2):
|
|
54
152
|
```bash
|
|
55
|
-
PACKET_JSON="<data.written_manifest_path>" node --input-type=module - <<'EOF'
|
|
153
|
+
PACKET_JSON="<data.written_manifest_path>" ROOT_DIR="<PROJECT_ROOT>" node --input-type=module - <<'EOF'
|
|
56
154
|
import fs from "node:fs/promises";
|
|
57
155
|
import { extractNovelAskGate, loadNovelAskAnswerIfPresent } from "./dist/instruction-gates.js";
|
|
58
156
|
|
|
59
|
-
const
|
|
157
|
+
const rootDir = process.env.ROOT_DIR ?? process.cwd();
|
|
158
|
+
|
|
159
|
+
async function readJson(path, label) {
|
|
160
|
+
if (!path) throw new Error(`${label} is required.`);
|
|
161
|
+
let text;
|
|
162
|
+
try {
|
|
163
|
+
text = await fs.readFile(path, "utf8");
|
|
164
|
+
} catch (err) {
|
|
165
|
+
const code = err && typeof err === "object" && "code" in err ? err.code : undefined;
|
|
166
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
167
|
+
throw new Error(`${label}: failed to read ${path}${code === "ENOENT" ? " (not found)" : ` (${message})`}`);
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(text);
|
|
171
|
+
} catch {
|
|
172
|
+
throw new Error(`${label}: invalid JSON in ${path}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const packet = await readJson(process.env.PACKET_JSON, "PACKET_JSON");
|
|
60
177
|
const gate = extractNovelAskGate(packet);
|
|
61
178
|
if (!gate) process.exit(0);
|
|
62
|
-
const answer = await loadNovelAskAnswerIfPresent(
|
|
179
|
+
const answer = await loadNovelAskAnswerIfPresent(rootDir, gate);
|
|
63
180
|
if (answer) {
|
|
64
181
|
console.error("NOVEL_ASK gate: OK");
|
|
65
182
|
process.exit(0);
|
|
@@ -104,18 +221,37 @@ EOF
|
|
|
104
221
|
|
|
105
222
|
```bash
|
|
106
223
|
mkdir -p staging/novel-ask
|
|
107
|
-
PACKET_JSON="<data.written_manifest_path>" ANSWERS_JSON="staging/novel-ask/answers.json" node --input-type=module - <<'EOF'
|
|
224
|
+
PACKET_JSON="<data.written_manifest_path>" ANSWERS_JSON="staging/novel-ask/answers.json" ROOT_DIR="<PROJECT_ROOT>" node --input-type=module - <<'EOF'
|
|
108
225
|
import fs from "node:fs/promises";
|
|
109
226
|
import { dirname } from "node:path";
|
|
110
227
|
import { extractNovelAskGate, requireNovelAskAnswer } from "./dist/instruction-gates.js";
|
|
111
228
|
import { parseNovelAskAnswerSpec, validateNovelAskAnswerAgainstQuestionSpec } from "./dist/novel-ask.js";
|
|
112
229
|
import { resolveProjectRelativePath } from "./dist/safe-path.js";
|
|
113
230
|
|
|
114
|
-
const
|
|
231
|
+
const rootDir = process.env.ROOT_DIR ?? process.cwd();
|
|
232
|
+
|
|
233
|
+
async function readJson(path, label) {
|
|
234
|
+
if (!path) throw new Error(`${label} is required.`);
|
|
235
|
+
let text;
|
|
236
|
+
try {
|
|
237
|
+
text = await fs.readFile(path, "utf8");
|
|
238
|
+
} catch (err) {
|
|
239
|
+
const code = err && typeof err === "object" && "code" in err ? err.code : undefined;
|
|
240
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
241
|
+
throw new Error(`${label}: failed to read ${path}${code === "ENOENT" ? " (not found)" : ` (${message})`}`);
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
return JSON.parse(text);
|
|
245
|
+
} catch {
|
|
246
|
+
throw new Error(`${label}: invalid JSON in ${path}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const packet = await readJson(process.env.PACKET_JSON, "PACKET_JSON");
|
|
115
251
|
const gate = extractNovelAskGate(packet);
|
|
116
252
|
if (!gate) process.exit(0);
|
|
117
253
|
|
|
118
|
-
const raw =
|
|
254
|
+
const raw = await readJson(process.env.ANSWERS_JSON, "ANSWERS_JSON");
|
|
119
255
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("ANSWERS_JSON must be a JSON object.");
|
|
120
256
|
if (typeof raw.answers !== "object" || raw.answers === null || Array.isArray(raw.answers)) throw new Error("ANSWERS_JSON.answers must be an object.");
|
|
121
257
|
|
|
@@ -128,31 +264,49 @@ const answerSpec = parseNovelAskAnswerSpec({
|
|
|
128
264
|
});
|
|
129
265
|
validateNovelAskAnswerAgainstQuestionSpec(gate.novel_ask, answerSpec);
|
|
130
266
|
|
|
131
|
-
const absAnswer = resolveProjectRelativePath(
|
|
267
|
+
const absAnswer = resolveProjectRelativePath(rootDir, gate.answer_path, "answer_path");
|
|
132
268
|
await fs.mkdir(dirname(absAnswer), { recursive: true });
|
|
133
269
|
await fs.writeFile(absAnswer, `${JSON.stringify(answerSpec, null, 2)}\n`, "utf8");
|
|
134
|
-
await requireNovelAskAnswer(
|
|
270
|
+
await requireNovelAskAnswer(rootDir, gate);
|
|
135
271
|
console.error(`NOVEL_ASK gate: wrote AnswerSpec to ${gate.answer_path}`);
|
|
136
272
|
EOF
|
|
137
273
|
```
|
|
138
274
|
|
|
139
275
|
通过后才允许继续派发 subagent。
|
|
140
276
|
|
|
141
|
-
### Step 4:
|
|
277
|
+
### Step 4: 执行 step(按 packet 路由)
|
|
142
278
|
|
|
143
|
-
从 `packet.agent.
|
|
279
|
+
从 `packet.agent.kind` / `packet.agent.name` 决定如何执行:
|
|
144
280
|
|
|
145
|
-
|
|
281
|
+
#### 4.1 `packet.agent.kind == "subagent"`:派发 subagent
|
|
282
|
+
|
|
283
|
+
用 Task 派发 `packet.agent.name` 对应 subagent,并把 `packet.manifest` 作为 user message 的 **context manifest**(JSON 原样传入)。
|
|
146
284
|
|
|
147
285
|
要求 subagent:
|
|
148
|
-
- 只写入 `staging
|
|
149
|
-
-
|
|
150
|
-
- 产出完成后停止,不要推进 checkpoint
|
|
286
|
+
- 只写入 `packet.expected_outputs[]` 指定路径(通常在 `staging/**`)
|
|
287
|
+
- 若 subagent 返回结构化 JSON:执行器需要将其写入 packet 指定的 JSON 输出路径(见 `expected_outputs.note`)
|
|
288
|
+
- 产出完成后停止,不要推进 checkpoint(validate/advance 由本适配器负责)
|
|
289
|
+
|
|
290
|
+
#### 4.2 `packet.agent.kind == "cli"`:执行/提示 CLI actions
|
|
291
|
+
|
|
292
|
+
不派发 subagent。先按需让用户完成人工 review,然后进入 Step 5 统一处理 `packet.next_actions[]`。
|
|
293
|
+
|
|
294
|
+
- 若 `packet.agent.name == "manual-review"`:先让用户手动检查 packet 提示的 review targets(常见于 `packet.manifest.inline.review_targets` 或 `packet.manifest.paths.*`),确认后再继续
|
|
295
|
+
|
|
296
|
+
若 `packet.agent.kind` 不是 `subagent|cli`:停止并提示用户检查 packet(不要执行未知命令)。
|
|
297
|
+
|
|
298
|
+
### Step 5: validate → advance → 返回控制权(必须)
|
|
299
|
+
|
|
300
|
+
执行完 Step 4 后,按顺序遍历 `packet.next_actions[]`(必要时做 `novel`→`${NOVEL}` 前缀替换):
|
|
301
|
+
|
|
302
|
+
1) 若命令是 `novel commit ...`:**停止**并提示用户手动执行(本适配器不自动 commit);commit 后运行 `${NOVEL} next --json`,再重新运行本 cli-step
|
|
303
|
+
|
|
304
|
+
2) 若命令是 `novel next` / `novel instructions ...`:这是跨 step 的提示命令,**不要在本次单步内执行**;只展示给用户作为下一步参考(如需执行 `instructions`,建议补 `--write-manifest`)
|
|
151
305
|
|
|
152
|
-
|
|
306
|
+
3) 其余命令(例如 `novel volume-review collect`、`novel validate ...`、`novel advance ...`):可以执行
|
|
307
|
+
- `validate` 失败(exit != 0)→ **立即停止**,提示用户修复产物后重试;不得执行后续 advance
|
|
308
|
+
- `advance` 仅在 validate 成功后执行
|
|
153
309
|
|
|
154
|
-
|
|
310
|
+
若该 step 的 `packet.next_actions[]` 不包含可执行的 validate/advance(常见于 `chapter:*:review` 或 `*:commit`):直接停下并展示 `packet.next_actions[]` 作为下一步提示。
|
|
155
311
|
|
|
156
|
-
|
|
157
|
-
- 再推进:`novel advance "<STEP_ID>"`
|
|
158
|
-
- 若 `novel next` 返回的是 `...:commit`:提示用户运行 `novel commit --chapter N`
|
|
312
|
+
安全建议:只执行预期的 `novel` 子命令(`validate/advance/commit/volume-review/lock/status/next/instructions` 等)。若 packet 包含未知/可疑命令:停止并让用户人工确认。
|