novel-writer-cli 0.0.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/advance-refine-invalidates-eval.test.js +37 -0
- package/dist/__tests__/character-voice.test.js +1 -1
- 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__/gate-decision.test.js +66 -0
- package/dist/__tests__/init.test.js +14 -6
- package/dist/__tests__/instructions-review-novel-ask-gate.test.js +31 -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 +172 -0
- package/dist/__tests__/orchestrator-state-write-path.test.js +59 -0
- package/dist/__tests__/quickstart-pipeline.test.js +346 -0
- package/dist/__tests__/safe-path-symlink.test.js +41 -0
- package/dist/__tests__/steps-id.test.js +23 -0
- package/dist/__tests__/validate-quickstart-prereqs.test.js +73 -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 +234 -52
- package/dist/checkpoint.js +93 -13
- package/dist/cli.js +318 -11
- package/dist/commit.js +1 -0
- package/dist/fs-utils.js +18 -3
- package/dist/gate-decision.js +59 -0
- package/dist/init.js +4 -1
- package/dist/instructions.js +483 -24
- package/dist/next-step.js +421 -34
- package/dist/platform-profile.js +3 -0
- package/dist/quickstart-validators.js +84 -0
- package/dist/quickstart.js +16 -0
- package/dist/safe-path.js +23 -1
- package/dist/steps.js +60 -17
- package/dist/validate.js +347 -3
- package/dist/volume-commit.js +101 -0
- package/dist/volume-planning.js +143 -0
- package/dist/volume-review.js +448 -0
- package/docs/user/README.md +0 -1
- package/docs/user/novel-cli.md +29 -0
- package/package.json +3 -2
- package/schemas/platform-profile.schema.json +5 -0
- 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
package/dist/next-step.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
1
2
|
import { join } from "node:path";
|
|
3
|
+
import { tryResolveVolumeChapterRange } from "./consistency-auditor.js";
|
|
4
|
+
import { NovelCliError } from "./errors.js";
|
|
2
5
|
import { pathExists, readJsonFile, readTextFile } from "./fs-utils.js";
|
|
6
|
+
import { computeGateDecision, detectHighConfidenceViolation } from "./gate-decision.js";
|
|
3
7
|
import { checkHookPolicy } from "./hook-policy.js";
|
|
4
8
|
import { loadPlatformProfile } from "./platform-profile.js";
|
|
9
|
+
import { QUICKSTART_STAGING_RELS } from "./quickstart.js";
|
|
10
|
+
import { validateQuickstartRulesSchema, validateQuickstartStyleProfileSchema } from "./quickstart-validators.js";
|
|
11
|
+
import { computeReviewNext } from "./volume-review.js";
|
|
5
12
|
import { computePrejudgeGuardrailsReport, loadPrejudgeGuardrailsReportIfFresh, prejudgeGuardrailsRelPath } from "./prejudge-guardrails.js";
|
|
6
13
|
import { summarizeNamingIssues } from "./naming-lint.js";
|
|
7
14
|
import { summarizeReadabilityIssues } from "./readability-lint.js";
|
|
8
15
|
import { computeTitlePolicyReport } from "./title-policy.js";
|
|
9
|
-
import { chapterRelPaths, formatStepId } from "./steps.js";
|
|
16
|
+
import { QUICKSTART_PHASES, chapterRelPaths, formatStepId } from "./steps.js";
|
|
17
|
+
import { isPlainObject } from "./type-guards.js";
|
|
18
|
+
import { computeVolumeNextStep } from "./volume-planning.js";
|
|
10
19
|
function normalizeStage(stage) {
|
|
11
20
|
if (stage === null || stage === undefined)
|
|
12
21
|
return null;
|
|
@@ -15,8 +24,7 @@ function normalizeStage(stage) {
|
|
|
15
24
|
return null;
|
|
16
25
|
}
|
|
17
26
|
async function checkHookPolicyForStage(args) {
|
|
18
|
-
const
|
|
19
|
-
const hookPolicy = loadedProfile?.profile.hook_policy;
|
|
27
|
+
const hookPolicy = args.loadedProfile?.profile.hook_policy;
|
|
20
28
|
if (!hookPolicy?.required)
|
|
21
29
|
return null;
|
|
22
30
|
let evalRaw;
|
|
@@ -60,10 +68,9 @@ async function checkHookPolicyForStage(args) {
|
|
|
60
68
|
return null;
|
|
61
69
|
}
|
|
62
70
|
async function checkTitlePolicyForStage(args) {
|
|
63
|
-
|
|
64
|
-
if (!loadedProfile)
|
|
71
|
+
if (!args.loadedProfile)
|
|
65
72
|
return null;
|
|
66
|
-
const titlePolicy = loadedProfile.profile.retention?.title_policy;
|
|
73
|
+
const titlePolicy = args.loadedProfile.profile.retention?.title_policy;
|
|
67
74
|
if (!titlePolicy?.enabled)
|
|
68
75
|
return null;
|
|
69
76
|
if (!args.hasChapter) {
|
|
@@ -87,7 +94,7 @@ async function checkTitlePolicyForStage(args) {
|
|
|
87
94
|
evidence: { ...args.evidence, titleFixCount: args.titleFixCount, error: message }
|
|
88
95
|
};
|
|
89
96
|
}
|
|
90
|
-
const report = computeTitlePolicyReport({ chapter: args.inflightChapter, chapterText, platformProfile: loadedProfile.profile });
|
|
97
|
+
const report = computeTitlePolicyReport({ chapter: args.inflightChapter, chapterText, platformProfile: args.loadedProfile.profile });
|
|
91
98
|
if (report.status === "pass" || report.status === "skipped")
|
|
92
99
|
return null;
|
|
93
100
|
if (!report.has_hard_violations && !titlePolicy.auto_fix)
|
|
@@ -118,8 +125,7 @@ async function checkTitlePolicyForStage(args) {
|
|
|
118
125
|
};
|
|
119
126
|
}
|
|
120
127
|
async function checkPrejudgeGuardrailsForStage(args) {
|
|
121
|
-
|
|
122
|
-
if (!loadedProfile)
|
|
128
|
+
if (!args.loadedProfile)
|
|
123
129
|
return null;
|
|
124
130
|
const chapterAbsPath = join(args.projectRootDir, args.chapterRelPath);
|
|
125
131
|
const cacheRelPath = prejudgeGuardrailsRelPath(args.inflightChapter);
|
|
@@ -128,8 +134,8 @@ async function checkPrejudgeGuardrailsForStage(args) {
|
|
|
128
134
|
rootDir: args.projectRootDir,
|
|
129
135
|
chapter: args.inflightChapter,
|
|
130
136
|
chapterAbsPath,
|
|
131
|
-
platformProfileRelPath: loadedProfile.relPath,
|
|
132
|
-
platformProfile: loadedProfile.profile
|
|
137
|
+
platformProfileRelPath: args.loadedProfile.relPath,
|
|
138
|
+
platformProfile: args.loadedProfile.profile
|
|
133
139
|
});
|
|
134
140
|
if (report)
|
|
135
141
|
cacheStatus = "hit";
|
|
@@ -139,8 +145,8 @@ async function checkPrejudgeGuardrailsForStage(args) {
|
|
|
139
145
|
rootDir: args.projectRootDir,
|
|
140
146
|
chapter: args.inflightChapter,
|
|
141
147
|
chapterAbsPath,
|
|
142
|
-
platformProfileRelPath: loadedProfile.relPath,
|
|
143
|
-
platformProfile: loadedProfile.profile
|
|
148
|
+
platformProfileRelPath: args.loadedProfile.relPath,
|
|
149
|
+
platformProfile: args.loadedProfile.profile
|
|
144
150
|
});
|
|
145
151
|
}
|
|
146
152
|
catch (err) {
|
|
@@ -193,13 +199,33 @@ async function checkPrejudgeGuardrailsForStage(args) {
|
|
|
193
199
|
}
|
|
194
200
|
};
|
|
195
201
|
}
|
|
196
|
-
|
|
202
|
+
async function computeChapterNextStep(projectRootDir, checkpoint) {
|
|
197
203
|
const inflightChapter = typeof checkpoint.inflight_chapter === "number" ? checkpoint.inflight_chapter : null;
|
|
198
204
|
const stage = normalizeStage(checkpoint.pipeline_stage);
|
|
199
205
|
const hookFixCount = typeof checkpoint.hook_fix_count === "number" ? checkpoint.hook_fix_count : 0;
|
|
200
206
|
const titleFixCount = typeof checkpoint.title_fix_count === "number" ? checkpoint.title_fix_count : 0;
|
|
201
|
-
|
|
202
|
-
|
|
207
|
+
if (inflightChapter !== null && inflightChapter < 1) {
|
|
208
|
+
throw new NovelCliError(".checkpoint.json.inflight_chapter must be an int >= 1 (or null).", 2);
|
|
209
|
+
}
|
|
210
|
+
if (stage === null || stage === "committed") {
|
|
211
|
+
if (inflightChapter !== null) {
|
|
212
|
+
throw new NovelCliError(`Checkpoint inconsistent: pipeline_stage=${stage ?? "null"} but inflight_chapter=${inflightChapter}. Set inflight_chapter to null.`, 2);
|
|
213
|
+
}
|
|
214
|
+
// Volume-end: enter deterministic volume review pipeline (issue #144).
|
|
215
|
+
if (checkpoint.last_completed_chapter > 0) {
|
|
216
|
+
let range = null;
|
|
217
|
+
try {
|
|
218
|
+
range = await tryResolveVolumeChapterRange({ rootDir: projectRootDir, volume: checkpoint.current_volume });
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// Best-effort: if we can't resolve range, fall back to chapter pipeline.
|
|
222
|
+
range = null;
|
|
223
|
+
}
|
|
224
|
+
if (range && checkpoint.last_completed_chapter === range.end) {
|
|
225
|
+
const next = await computeReviewNext(projectRootDir, checkpoint);
|
|
226
|
+
return { ...next, reason: `volume_end:${next.reason}` };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
203
229
|
const nextChapter = checkpoint.last_completed_chapter + 1;
|
|
204
230
|
return {
|
|
205
231
|
step: formatStepId({ kind: "chapter", chapter: nextChapter, stage: "draft" }),
|
|
@@ -207,6 +233,9 @@ export async function computeNextStep(projectRootDir, checkpoint) {
|
|
|
207
233
|
inflight: { chapter: null, pipeline_stage: stage }
|
|
208
234
|
};
|
|
209
235
|
}
|
|
236
|
+
if (inflightChapter === null) {
|
|
237
|
+
throw new NovelCliError(`Checkpoint inconsistent: pipeline_stage=${stage} requires inflight_chapter. Repair .checkpoint.json and rerun.`, 2);
|
|
238
|
+
}
|
|
210
239
|
const rel = chapterRelPaths(inflightChapter);
|
|
211
240
|
const hasChapter = await pathExists(join(projectRootDir, rel.staging.chapterMd));
|
|
212
241
|
const hasSummary = await pathExists(join(projectRootDir, rel.staging.summaryMd));
|
|
@@ -281,6 +310,7 @@ export async function computeNextStep(projectRootDir, checkpoint) {
|
|
|
281
310
|
evidence
|
|
282
311
|
};
|
|
283
312
|
}
|
|
313
|
+
const loadedProfile = await loadPlatformProfile(projectRootDir);
|
|
284
314
|
if (!hasEval) {
|
|
285
315
|
const titleGate = await checkTitlePolicyForStage({
|
|
286
316
|
projectRootDir,
|
|
@@ -290,7 +320,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
|
|
|
290
320
|
evidence,
|
|
291
321
|
titleFixCount,
|
|
292
322
|
hasChapter,
|
|
293
|
-
chapterRelPath: rel.staging.chapterMd
|
|
323
|
+
chapterRelPath: rel.staging.chapterMd,
|
|
324
|
+
loadedProfile
|
|
294
325
|
});
|
|
295
326
|
if (titleGate)
|
|
296
327
|
return titleGate;
|
|
@@ -309,7 +340,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
|
|
|
309
340
|
evidence,
|
|
310
341
|
titleFixCount,
|
|
311
342
|
hasChapter,
|
|
312
|
-
chapterRelPath: rel.staging.chapterMd
|
|
343
|
+
chapterRelPath: rel.staging.chapterMd,
|
|
344
|
+
loadedProfile
|
|
313
345
|
});
|
|
314
346
|
if (titleGate)
|
|
315
347
|
return titleGate;
|
|
@@ -320,7 +352,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
|
|
|
320
352
|
pipelineStage: stage,
|
|
321
353
|
evidence,
|
|
322
354
|
hookFixCount,
|
|
323
|
-
evalRelPath: rel.staging.evalJson
|
|
355
|
+
evalRelPath: rel.staging.evalJson,
|
|
356
|
+
loadedProfile
|
|
324
357
|
});
|
|
325
358
|
if (hookGate)
|
|
326
359
|
return hookGate;
|
|
@@ -330,7 +363,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
|
|
|
330
363
|
inflightChapter,
|
|
331
364
|
pipelineStage: stage,
|
|
332
365
|
evidence,
|
|
333
|
-
chapterRelPath: rel.staging.chapterMd
|
|
366
|
+
chapterRelPath: rel.staging.chapterMd,
|
|
367
|
+
loadedProfile
|
|
334
368
|
});
|
|
335
369
|
if (guardrailsGate)
|
|
336
370
|
return guardrailsGate;
|
|
@@ -358,6 +392,7 @@ export async function computeNextStep(projectRootDir, checkpoint) {
|
|
|
358
392
|
evidence
|
|
359
393
|
};
|
|
360
394
|
}
|
|
395
|
+
const loadedProfile = await loadPlatformProfile(projectRootDir);
|
|
361
396
|
const titleGate = await checkTitlePolicyForStage({
|
|
362
397
|
projectRootDir,
|
|
363
398
|
stagePrefix: "judged",
|
|
@@ -366,7 +401,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
|
|
|
366
401
|
evidence,
|
|
367
402
|
titleFixCount,
|
|
368
403
|
hasChapter,
|
|
369
|
-
chapterRelPath: rel.staging.chapterMd
|
|
404
|
+
chapterRelPath: rel.staging.chapterMd,
|
|
405
|
+
loadedProfile
|
|
370
406
|
});
|
|
371
407
|
if (titleGate)
|
|
372
408
|
return titleGate;
|
|
@@ -377,7 +413,8 @@ export async function computeNextStep(projectRootDir, checkpoint) {
|
|
|
377
413
|
pipelineStage: stage,
|
|
378
414
|
evidence,
|
|
379
415
|
hookFixCount,
|
|
380
|
-
evalRelPath: rel.staging.evalJson
|
|
416
|
+
evalRelPath: rel.staging.evalJson,
|
|
417
|
+
loadedProfile
|
|
381
418
|
});
|
|
382
419
|
if (hookGate)
|
|
383
420
|
return hookGate;
|
|
@@ -387,22 +424,372 @@ export async function computeNextStep(projectRootDir, checkpoint) {
|
|
|
387
424
|
inflightChapter,
|
|
388
425
|
pipelineStage: stage,
|
|
389
426
|
evidence,
|
|
390
|
-
chapterRelPath: rel.staging.chapterMd
|
|
427
|
+
chapterRelPath: rel.staging.chapterMd,
|
|
428
|
+
loadedProfile
|
|
391
429
|
});
|
|
392
430
|
if (guardrailsGate)
|
|
393
431
|
return guardrailsGate;
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
432
|
+
// Gate decision: deterministic mapping from QualityJudge outputs → next action.
|
|
433
|
+
let evalRaw;
|
|
434
|
+
try {
|
|
435
|
+
evalRaw = await readJsonFile(join(projectRootDir, rel.staging.evalJson));
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
439
|
+
return {
|
|
440
|
+
step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "judge" }),
|
|
441
|
+
reason: `judged:eval_read_failed`,
|
|
442
|
+
inflight: { chapter: inflightChapter, pipeline_stage: stage },
|
|
443
|
+
evidence: { ...evidence, error: message }
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
if (!isPlainObject(evalRaw)) {
|
|
447
|
+
return {
|
|
448
|
+
step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "judge" }),
|
|
449
|
+
reason: `judged:eval_invalid`,
|
|
450
|
+
inflight: { chapter: inflightChapter, pipeline_stage: stage },
|
|
451
|
+
evidence: { ...evidence }
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
const evalObj = evalRaw;
|
|
455
|
+
const overall = typeof evalObj.overall_final === "number" ? evalObj.overall_final : typeof evalObj.overall === "number" ? evalObj.overall : null;
|
|
456
|
+
if (overall === null || !Number.isFinite(overall)) {
|
|
457
|
+
return {
|
|
458
|
+
step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "judge" }),
|
|
459
|
+
reason: `judged:eval_missing_overall`,
|
|
460
|
+
inflight: { chapter: inflightChapter, pipeline_stage: stage },
|
|
461
|
+
evidence: { ...evidence }
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
const revisionCount = typeof checkpoint.revision_count === "number" && Number.isInteger(checkpoint.revision_count) && checkpoint.revision_count >= 0
|
|
465
|
+
? checkpoint.revision_count
|
|
466
|
+
: 0;
|
|
467
|
+
const violation = detectHighConfidenceViolation(evalRaw);
|
|
468
|
+
const maxRevisions = typeof loadedProfile?.profile.scoring?.max_revisions === "number" &&
|
|
469
|
+
Number.isInteger(loadedProfile.profile.scoring.max_revisions) &&
|
|
470
|
+
loadedProfile.profile.scoring.max_revisions >= 0
|
|
471
|
+
? loadedProfile.profile.scoring.max_revisions
|
|
472
|
+
: null;
|
|
473
|
+
const gateDecision = computeGateDecision({
|
|
474
|
+
overall_final: overall,
|
|
475
|
+
revision_count: revisionCount,
|
|
476
|
+
has_high_confidence_violation: violation.has_high_confidence_violation,
|
|
477
|
+
...(maxRevisions === null ? {} : { max_revisions: maxRevisions })
|
|
478
|
+
});
|
|
479
|
+
const gateEvidence = {
|
|
480
|
+
...evidence,
|
|
481
|
+
gate: {
|
|
482
|
+
decision: gateDecision,
|
|
483
|
+
overall_final: overall,
|
|
484
|
+
revision_count: revisionCount,
|
|
485
|
+
max_revisions: maxRevisions,
|
|
486
|
+
has_high_confidence_violation: violation.has_high_confidence_violation,
|
|
487
|
+
high_confidence_violations: violation.high_confidence_violations.slice(0, 10)
|
|
488
|
+
},
|
|
489
|
+
quality_judge: {
|
|
490
|
+
recommendation: typeof evalObj.recommendation === "string" ? evalObj.recommendation : null
|
|
491
|
+
}
|
|
399
492
|
};
|
|
493
|
+
if (gateDecision === "pass") {
|
|
494
|
+
return {
|
|
495
|
+
step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "commit" }),
|
|
496
|
+
reason: "judged:gate:pass",
|
|
497
|
+
inflight: { chapter: inflightChapter, pipeline_stage: stage },
|
|
498
|
+
evidence: gateEvidence
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
if (gateDecision === "force_passed") {
|
|
502
|
+
return {
|
|
503
|
+
step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "commit" }),
|
|
504
|
+
reason: "judged:gate:force_passed",
|
|
505
|
+
inflight: { chapter: inflightChapter, pipeline_stage: stage },
|
|
506
|
+
evidence: gateEvidence
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
if (gateDecision === "polish") {
|
|
510
|
+
return {
|
|
511
|
+
step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "refine" }),
|
|
512
|
+
reason: "judged:gate:polish",
|
|
513
|
+
inflight: { chapter: inflightChapter, pipeline_stage: stage },
|
|
514
|
+
evidence: gateEvidence
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
if (gateDecision === "revise") {
|
|
518
|
+
return {
|
|
519
|
+
step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "draft" }),
|
|
520
|
+
reason: "judged:gate:revise",
|
|
521
|
+
inflight: { chapter: inflightChapter, pipeline_stage: stage },
|
|
522
|
+
evidence: gateEvidence
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
if (gateDecision === "pause_for_user" || gateDecision === "pause_for_user_force_rewrite") {
|
|
526
|
+
return {
|
|
527
|
+
step: formatStepId({ kind: "chapter", chapter: inflightChapter, stage: "review" }),
|
|
528
|
+
reason: `judged:gate:${gateDecision}`,
|
|
529
|
+
inflight: { chapter: inflightChapter, pipeline_stage: stage },
|
|
530
|
+
evidence: gateEvidence
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
const _exhaustive = gateDecision;
|
|
534
|
+
throw new NovelCliError(`Unsupported gate decision: ${String(_exhaustive)}`, 2);
|
|
400
535
|
}
|
|
401
|
-
// Unknown stage:
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
536
|
+
// Unknown stage: upstream parseCheckpoint validates enum so this should be unreachable.
|
|
537
|
+
throw new NovelCliError(`Checkpoint has unexpected pipeline_stage=${stage}. This should not happen; repair .checkpoint.json and rerun.`, 2);
|
|
538
|
+
}
|
|
539
|
+
function notImplementedState(state) {
|
|
540
|
+
throw new NovelCliError(`Not implemented: orchestrator_state=${state}`, 2);
|
|
541
|
+
}
|
|
542
|
+
async function countContractArtifacts(rootDir) {
|
|
543
|
+
const absDir = join(rootDir, QUICKSTART_STAGING_RELS.contractsDir);
|
|
544
|
+
const hasDir = await pathExists(absDir);
|
|
545
|
+
if (!hasDir)
|
|
546
|
+
return { hasDir, fileCount: 0, sample: [], degraded: false };
|
|
547
|
+
try {
|
|
548
|
+
const entries = await readdir(absDir, { withFileTypes: true });
|
|
549
|
+
const files = entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
550
|
+
const sample = files
|
|
551
|
+
.filter((n) => n.endsWith(".json"))
|
|
552
|
+
.sort()
|
|
553
|
+
.slice(0, 3);
|
|
554
|
+
const fileCount = files.filter((n) => n.endsWith(".json")).length;
|
|
555
|
+
return { hasDir, fileCount, sample, degraded: false };
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
559
|
+
// If the dir exists but is unreadable, treat as present but degraded.
|
|
560
|
+
return { hasDir, fileCount: 0, sample: [], degraded: true, error: message };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async function computeQuickStartNextStep(projectRootDir, checkpoint) {
|
|
564
|
+
const stage = normalizeStage(checkpoint.pipeline_stage);
|
|
565
|
+
const inflight = typeof checkpoint.inflight_chapter === "number" ? checkpoint.inflight_chapter : null;
|
|
566
|
+
if ((stage === null || stage === "committed") && inflight !== null) {
|
|
567
|
+
throw new NovelCliError(`Checkpoint inconsistent for QUICK_START: pipeline_stage=${stage ?? "null"} but inflight_chapter=${inflight}. Set inflight_chapter to null.`, 2);
|
|
568
|
+
}
|
|
569
|
+
if (stage !== null && stage !== "committed") {
|
|
570
|
+
throw new NovelCliError(`Checkpoint inconsistent for QUICK_START: pipeline_stage=${stage} (expected null or committed). Finish the chapter pipeline or repair .checkpoint.json.`, 2);
|
|
571
|
+
}
|
|
572
|
+
const rulesAbs = join(projectRootDir, QUICKSTART_STAGING_RELS.rulesJson);
|
|
573
|
+
const styleAbs = join(projectRootDir, QUICKSTART_STAGING_RELS.styleProfileJson);
|
|
574
|
+
const trialAbs = join(projectRootDir, QUICKSTART_STAGING_RELS.trialChapterMd);
|
|
575
|
+
const evalAbs = join(projectRootDir, QUICKSTART_STAGING_RELS.evaluationJson);
|
|
576
|
+
const rulesExists = await pathExists(rulesAbs);
|
|
577
|
+
const contracts = await countContractArtifacts(projectRootDir);
|
|
578
|
+
const styleExists = await pathExists(styleAbs);
|
|
579
|
+
const trialExists = await pathExists(trialAbs);
|
|
580
|
+
const evalExists = await pathExists(evalAbs);
|
|
581
|
+
let rulesOk = false;
|
|
582
|
+
let rulesError = null;
|
|
583
|
+
if (rulesExists) {
|
|
584
|
+
try {
|
|
585
|
+
await validateQuickstartRulesSchema(rulesAbs, { trimRequiredStrings: true });
|
|
586
|
+
rulesOk = true;
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
rulesError = err instanceof Error ? err.message : String(err);
|
|
590
|
+
rulesOk = false;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
let styleOk = false;
|
|
594
|
+
let styleError = null;
|
|
595
|
+
if (styleExists) {
|
|
596
|
+
try {
|
|
597
|
+
await validateQuickstartStyleProfileSchema(styleAbs);
|
|
598
|
+
styleOk = true;
|
|
599
|
+
}
|
|
600
|
+
catch (err) {
|
|
601
|
+
styleError = err instanceof Error ? err.message : String(err);
|
|
602
|
+
styleOk = false;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
let trialOk = false;
|
|
606
|
+
let trialError = null;
|
|
607
|
+
if (trialExists) {
|
|
608
|
+
try {
|
|
609
|
+
const text = await readTextFile(trialAbs);
|
|
610
|
+
if (text.trim().length === 0)
|
|
611
|
+
throw new Error("empty trial chapter");
|
|
612
|
+
trialOk = true;
|
|
613
|
+
}
|
|
614
|
+
catch (err) {
|
|
615
|
+
trialError = err instanceof Error ? err.message : String(err);
|
|
616
|
+
trialOk = false;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
let evalOk = false;
|
|
620
|
+
let evalError = null;
|
|
621
|
+
if (evalExists) {
|
|
622
|
+
try {
|
|
623
|
+
const raw = await readJsonFile(evalAbs);
|
|
624
|
+
if (!isPlainObject(raw))
|
|
625
|
+
throw new Error("expected JSON object");
|
|
626
|
+
evalOk = true;
|
|
627
|
+
}
|
|
628
|
+
catch (err) {
|
|
629
|
+
evalError = err instanceof Error ? err.message : String(err);
|
|
630
|
+
evalOk = false;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const evidence = {
|
|
634
|
+
checkpoint: {
|
|
635
|
+
quickstart_phase: (checkpoint.quickstart_phase ?? null)
|
|
636
|
+
},
|
|
637
|
+
staging: {
|
|
638
|
+
rulesExists,
|
|
639
|
+
rulesOk,
|
|
640
|
+
...(rulesError ? { rulesError } : {}),
|
|
641
|
+
contracts: {
|
|
642
|
+
hasDir: contracts.hasDir,
|
|
643
|
+
jsonFileCount: contracts.fileCount,
|
|
644
|
+
sample: contracts.sample,
|
|
645
|
+
degraded: contracts.degraded,
|
|
646
|
+
...(contracts.error ? { error: contracts.error } : {})
|
|
647
|
+
},
|
|
648
|
+
styleExists,
|
|
649
|
+
styleOk,
|
|
650
|
+
...(styleError ? { styleError } : {}),
|
|
651
|
+
trialExists,
|
|
652
|
+
trialOk,
|
|
653
|
+
...(trialError ? { trialError } : {}),
|
|
654
|
+
evalExists,
|
|
655
|
+
evalOk,
|
|
656
|
+
...(evalError ? { evalError } : {})
|
|
657
|
+
}
|
|
407
658
|
};
|
|
659
|
+
let selected;
|
|
660
|
+
let selectedPhase;
|
|
661
|
+
if (!rulesOk) {
|
|
662
|
+
selectedPhase = "world";
|
|
663
|
+
selected = {
|
|
664
|
+
step: formatStepId({ kind: "quickstart", phase: "world" }),
|
|
665
|
+
reason: "quickstart:world",
|
|
666
|
+
inflight: { chapter: null, pipeline_stage: null },
|
|
667
|
+
evidence
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
else if (!contracts.hasDir || contracts.fileCount === 0) {
|
|
671
|
+
selectedPhase = "characters";
|
|
672
|
+
selected = {
|
|
673
|
+
step: formatStepId({ kind: "quickstart", phase: "characters" }),
|
|
674
|
+
reason: "quickstart:characters",
|
|
675
|
+
inflight: { chapter: null, pipeline_stage: null },
|
|
676
|
+
evidence
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
else if (!styleOk) {
|
|
680
|
+
selectedPhase = "style";
|
|
681
|
+
selected = {
|
|
682
|
+
step: formatStepId({ kind: "quickstart", phase: "style" }),
|
|
683
|
+
reason: "quickstart:style",
|
|
684
|
+
inflight: { chapter: null, pipeline_stage: null },
|
|
685
|
+
evidence
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
else if (!trialOk) {
|
|
689
|
+
selectedPhase = "trial";
|
|
690
|
+
selected = {
|
|
691
|
+
step: formatStepId({ kind: "quickstart", phase: "trial" }),
|
|
692
|
+
reason: "quickstart:trial",
|
|
693
|
+
inflight: { chapter: null, pipeline_stage: null },
|
|
694
|
+
evidence
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
else if (!evalOk) {
|
|
698
|
+
selectedPhase = "results";
|
|
699
|
+
selected = {
|
|
700
|
+
step: formatStepId({ kind: "quickstart", phase: "results" }),
|
|
701
|
+
reason: "quickstart:results",
|
|
702
|
+
inflight: { chapter: null, pipeline_stage: null },
|
|
703
|
+
evidence
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
selectedPhase = "results";
|
|
708
|
+
selected = {
|
|
709
|
+
step: formatStepId({ kind: "quickstart", phase: "results" }),
|
|
710
|
+
reason: "quickstart:results:artifacts_present",
|
|
711
|
+
inflight: { chapter: null, pipeline_stage: null },
|
|
712
|
+
evidence
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
const selectedPhaseIdx = QUICKSTART_PHASES.indexOf(selectedPhase);
|
|
716
|
+
if (selectedPhaseIdx < 0) {
|
|
717
|
+
throw new NovelCliError(`Internal error: invalid quickstart phase=${selectedPhase}`, 2);
|
|
718
|
+
}
|
|
719
|
+
const checkpointPhase = checkpoint.quickstart_phase ?? null;
|
|
720
|
+
if (checkpointPhase) {
|
|
721
|
+
const checkpointIdx = QUICKSTART_PHASES.indexOf(checkpointPhase);
|
|
722
|
+
if (checkpointIdx >= 0 && selectedPhaseIdx < checkpointIdx) {
|
|
723
|
+
const expectedPath = (() => {
|
|
724
|
+
switch (selectedPhase) {
|
|
725
|
+
case "world":
|
|
726
|
+
return QUICKSTART_STAGING_RELS.rulesJson;
|
|
727
|
+
case "characters":
|
|
728
|
+
return QUICKSTART_STAGING_RELS.contractsDir;
|
|
729
|
+
case "style":
|
|
730
|
+
return QUICKSTART_STAGING_RELS.styleProfileJson;
|
|
731
|
+
case "trial":
|
|
732
|
+
return QUICKSTART_STAGING_RELS.trialChapterMd;
|
|
733
|
+
case "results":
|
|
734
|
+
return QUICKSTART_STAGING_RELS.evaluationJson;
|
|
735
|
+
default: {
|
|
736
|
+
const _exhaustive = selectedPhase;
|
|
737
|
+
return String(_exhaustive);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
})();
|
|
741
|
+
return {
|
|
742
|
+
...selected,
|
|
743
|
+
reason: `quickstart:recovery_blocked:${checkpointPhase}`,
|
|
744
|
+
evidence: {
|
|
745
|
+
...evidence,
|
|
746
|
+
recovery_blocked: {
|
|
747
|
+
checkpoint_phase: checkpointPhase,
|
|
748
|
+
checkpoint_phase_idx: checkpointIdx,
|
|
749
|
+
inferred_phase: selectedPhase,
|
|
750
|
+
inferred_phase_idx: selectedPhaseIdx,
|
|
751
|
+
inferred_reason: selected.reason,
|
|
752
|
+
expected_path: expectedPath
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return selected;
|
|
759
|
+
}
|
|
760
|
+
export async function computeNextStep(projectRootDir, checkpoint) {
|
|
761
|
+
switch (checkpoint.orchestrator_state) {
|
|
762
|
+
case "WRITING":
|
|
763
|
+
case "CHAPTER_REWRITE":
|
|
764
|
+
return await computeChapterNextStep(projectRootDir, checkpoint);
|
|
765
|
+
case "ERROR_RETRY": {
|
|
766
|
+
const stage = normalizeStage(checkpoint.pipeline_stage);
|
|
767
|
+
const inflight = typeof checkpoint.inflight_chapter === "number" ? checkpoint.inflight_chapter : null;
|
|
768
|
+
let normalizedCheckpoint = checkpoint;
|
|
769
|
+
let healPrefix = "";
|
|
770
|
+
// Only auto-heal invariants when explicitly in ERROR_RETRY.
|
|
771
|
+
if ((stage === null || stage === "committed") && inflight !== null) {
|
|
772
|
+
normalizedCheckpoint = { ...checkpoint, inflight_chapter: null };
|
|
773
|
+
healPrefix = "healed_drop_inflight:";
|
|
774
|
+
}
|
|
775
|
+
else if (stage !== null && stage !== "committed" && inflight === null) {
|
|
776
|
+
normalizedCheckpoint = { ...checkpoint, inflight_chapter: checkpoint.last_completed_chapter + 1 };
|
|
777
|
+
healPrefix = "healed_infer_inflight:";
|
|
778
|
+
}
|
|
779
|
+
const next = await computeChapterNextStep(projectRootDir, normalizedCheckpoint);
|
|
780
|
+
return { ...next, reason: `error_retry:${healPrefix}${next.reason}` };
|
|
781
|
+
}
|
|
782
|
+
case "INIT": {
|
|
783
|
+
const next = await computeQuickStartNextStep(projectRootDir, checkpoint);
|
|
784
|
+
return { ...next, reason: `init:${next.reason}` };
|
|
785
|
+
}
|
|
786
|
+
case "QUICK_START":
|
|
787
|
+
return await computeQuickStartNextStep(projectRootDir, checkpoint);
|
|
788
|
+
case "VOL_PLANNING":
|
|
789
|
+
return await computeVolumeNextStep(projectRootDir, checkpoint);
|
|
790
|
+
case "VOL_REVIEW":
|
|
791
|
+
return await computeReviewNext(projectRootDir, checkpoint);
|
|
792
|
+
default:
|
|
793
|
+
return notImplementedState(checkpoint.orchestrator_state);
|
|
794
|
+
}
|
|
408
795
|
}
|
package/dist/platform-profile.js
CHANGED
|
@@ -122,6 +122,9 @@ function parseScoringPolicy(raw, file) {
|
|
|
122
122
|
genre_drive_type: requireStringField(obj, "genre_drive_type", file),
|
|
123
123
|
weight_profile_id: requireStringField(obj, "weight_profile_id", file)
|
|
124
124
|
};
|
|
125
|
+
if (obj.max_revisions !== undefined) {
|
|
126
|
+
out.max_revisions = requireNonNegativeIntValue(obj.max_revisions, file, "scoring.max_revisions");
|
|
127
|
+
}
|
|
125
128
|
if (obj.weight_overrides !== undefined) {
|
|
126
129
|
if (!isPlainObject(obj.weight_overrides))
|
|
127
130
|
throw new NovelCliError(`Invalid ${file}: 'scoring.weight_overrides' must be an object.`, 2);
|
|
@@ -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
|
+
}
|