@lingjingai/scriptctl 0.13.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +33 -49
  3. package/dist/cli.js.map +1 -1
  4. package/dist/common.d.ts +11 -4
  5. package/dist/common.js +83 -61
  6. package/dist/common.js.map +1 -1
  7. package/dist/domain/direct-core.d.ts +27 -4
  8. package/dist/domain/direct-core.js +281 -55
  9. package/dist/domain/direct-core.js.map +1 -1
  10. package/dist/domain/script-core.d.ts +15 -0
  11. package/dist/domain/script-core.js +207 -40
  12. package/dist/domain/script-core.js.map +1 -1
  13. package/dist/help-text.js +45 -315
  14. package/dist/help-text.js.map +1 -1
  15. package/dist/infra/providers.d.ts +10 -36
  16. package/dist/infra/providers.js +144 -300
  17. package/dist/infra/providers.js.map +1 -1
  18. package/dist/usecases/direct.js +342 -60
  19. package/dist/usecases/direct.js.map +1 -1
  20. package/dist/usecases/doctor.js +2 -5
  21. package/dist/usecases/doctor.js.map +1 -1
  22. package/dist/usecases/parse.js +104 -14
  23. package/dist/usecases/parse.js.map +1 -1
  24. package/dist/usecases/script.d.ts +7 -2
  25. package/dist/usecases/script.js +180 -12
  26. package/dist/usecases/script.js.map +1 -1
  27. package/package.json +2 -3
  28. package/dist/domain/asset-registry.d.ts +0 -141
  29. package/dist/domain/asset-registry.js +0 -318
  30. package/dist/domain/asset-registry.js.map +0 -1
  31. package/dist/domain/collision-detector.d.ts +0 -83
  32. package/dist/domain/collision-detector.js +0 -248
  33. package/dist/domain/collision-detector.js.map +0 -1
  34. package/dist/infra/default-writing-prompt.d.ts +0 -31
  35. package/dist/infra/default-writing-prompt.js +0 -50
  36. package/dist/infra/default-writing-prompt.js.map +0 -1
  37. package/dist/infra/default-writing-prompt.md +0 -115
  38. package/dist/infra/gemini-writer.d.ts +0 -107
  39. package/dist/infra/gemini-writer.js +0 -207
  40. package/dist/infra/gemini-writer.js.map +0 -1
  41. package/dist/usecases/episode.d.ts +0 -48
  42. package/dist/usecases/episode.js +0 -1242
  43. package/dist/usecases/episode.js.map +0 -1
@@ -1,1242 +0,0 @@
1
- /**
2
- * `scriptctl episode` subcommand group.
3
- *
4
- * Atomic per-episode production loop for the creative mainline (灵感线 / 小说线 / 原创线
5
- * → 剧本). The five commands compose into three modes:
6
- *
7
- * - **single**: user manually drives `draft → preview → commit` for each episode
8
- * - **batch**: `episode batch --range a-b` internally loops draft → preview;
9
- * user commits each episode after reviewing
10
- * - **mixed**: first N episodes single (to lock in style), then batch
11
- *
12
- * The orchestrator is intentionally thin. Heavy lifting lives elsewhere:
13
- * - prompt assembly + provider call → `infra/gemini-writer.ts`
14
- * - markdown parsing → `domain/direct-core.ts::parseMarkdownBatch(.., {fragmentMode:true})`
15
- * - asset state → `domain/asset-registry.ts`
16
- * - collision detection → `domain/collision-detector.ts`
17
- * - gateway upload → existing `infra/script-output-api.ts` (commit calls it)
18
- */
19
- import * as fs from "node:fs";
20
- import * as path from "node:path";
21
- import { CliError, EXIT_INPUT, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, MARKDOWN_BATCH_PROMPT_SPEC, SCRIPT_SCHEMA_VERSION, exists, isDebug, readJson, readText, sha256Text, writeJson, writeText, } from "../common.js";
22
- import { applyFragment, defaultRegistryPath, loadRegistry, rollbackEpisode, saveRegistry, } from "../domain/asset-registry.js";
23
- import { detectCollisions, hasHardCollision, summarizeReports, } from "../domain/collision-detector.js";
24
- import { isTechnicalEpisodeTitle, mergeEpisodeResults, parseMarkdownBatch } from "../domain/direct-core.js";
25
- import { validateScript } from "../domain/script-core.js";
26
- import { ScriptOutputApiError } from "../infra/script-output-api.js";
27
- import { resolveOutputMode } from "../infra/script-output-store.js";
28
- import { summarizeIssues } from "./direct.js";
29
- import { apiErrorToCli, currentRevisionOrZero, scriptOutputClient, sortDeep, } from "./script.js";
30
- import { DEFAULT_WRITING_PROMPT, injectSpec } from "../infra/default-writing-prompt.js";
31
- import { dedupeRefs, draftEpisode, loadWriterContext, parseRefArg, } from "../infra/gemini-writer.js";
32
- import { GeminiProvider, MockProvider } from "../infra/providers.js";
33
- /**
34
- * Concentrate the parser → registry type bridge here. `parseMarkdownBatch` returns
35
- * a generic `Dict` (so it can be reused across direct init and episode fragments),
36
- * but downstream registry / collision-detector accept the structurally-narrower
37
- * `FragmentLike`. Casting in one place keeps the call sites clean and the unsafe
38
- * boundary visible.
39
- */
40
- function asFragment(d) {
41
- return d;
42
- }
43
- /** Default episode-writer provider. Override via `--provider`. */
44
- const DEFAULT_EPISODE_PROVIDER = "gemini";
45
- function resolveContextSpec(opts) {
46
- let refs = [];
47
- if (Array.isArray(opts.ref) && opts.ref.length > 0) {
48
- try {
49
- refs = dedupeRefs(opts.ref.map((raw) => parseRefArg(raw)));
50
- }
51
- catch (exc) {
52
- throw new CliError("DRAFT FAILED: --ref format invalid", "Invalid --ref format.", {
53
- exitCode: EXIT_USAGE,
54
- required: ['--ref "<title>=<path>"'],
55
- received: [String(exc.message ?? exc)],
56
- nextSteps: ['Example: --ref "全局设定=analysis-workspace/全局设定.md"'],
57
- });
58
- }
59
- }
60
- return { refs, outlineTemplate: opts.outline };
61
- }
62
- // ---------------------------------------------------------------------------
63
- // Path helpers
64
- // ---------------------------------------------------------------------------
65
- function padded(n) {
66
- return String(n).padStart(2, "0");
67
- }
68
- function episodeDir(workspace) {
69
- return path.join(workspace, "episodes");
70
- }
71
- function epMdPath(workspace, n) {
72
- return path.join(episodeDir(workspace), `ep${padded(n)}.md`);
73
- }
74
- function epJsonPath(workspace, n) {
75
- return path.join(episodeDir(workspace), `ep${padded(n)}.json`);
76
- }
77
- function epLintPath(workspace, n) {
78
- return path.join(episodeDir(workspace), `ep${padded(n)}.lint.json`);
79
- }
80
- function epCollisionPath(workspace, n) {
81
- return path.join(episodeDir(workspace), `ep${padded(n)}.collision.json`);
82
- }
83
- function epHistoryDir(workspace, n) {
84
- return path.join(episodeDir(workspace), `ep${padded(n)}.history`);
85
- }
86
- function epMarkerPath(workspace, n) {
87
- return path.join(episodeDir(workspace), `.done-${padded(n)}`);
88
- }
89
- function metaPath(workspace) {
90
- return path.join(episodeDir(workspace), "meta.json");
91
- }
92
- function outlinePath(workspace, n) {
93
- return path.join(episodeDir(workspace), `ep${padded(n)}.outline.md`);
94
- }
95
- function mergedDir(workspace) {
96
- return path.join(episodeDir(workspace), ".merged");
97
- }
98
- function mergedScriptPath(workspace) {
99
- return path.join(mergedDir(workspace), "script.json");
100
- }
101
- function mergeReceiptPath(workspace) {
102
- return path.join(mergedDir(workspace), "merge-receipt.json");
103
- }
104
- function pushReceiptPath(workspace) {
105
- return path.join(mergedDir(workspace), "push-receipt.json");
106
- }
107
- // ---------------------------------------------------------------------------
108
- // Episode title extraction (from outline H1) — hard-failed in draft if absent
109
- // ---------------------------------------------------------------------------
110
- const EPISODE_TITLE_RE = /^#+\s*第\s*\d+\s*集\s*[::\-—\s]+(.+?)\s*$/;
111
- function resolveOutlinePath(workspace, n, outlineOverride) {
112
- if (!outlineOverride)
113
- return outlinePath(workspace, n);
114
- // Accept literal path or {NN}-template (same convention as loadWriterContext).
115
- return outlineOverride.replace(/\{NN\}/g, padded(n));
116
- }
117
- function extractEpisodeTitle(workspace, n, outlineOverride) {
118
- const p = resolveOutlinePath(workspace, n, outlineOverride);
119
- if (!exists(p)) {
120
- throw new CliError("DRAFT FAILED: outline file missing", "Outline file missing.", {
121
- exitCode: EXIT_INPUT,
122
- required: [p],
123
- received: ["no file at that path"],
124
- nextSteps: [
125
- `Create ${p} with a heading like "# 第 ${n} 集:副标题" + 集纲内容.`,
126
- ],
127
- });
128
- }
129
- const text = readText(p);
130
- for (const rawLine of text.split(/\r?\n/)) {
131
- const line = rawLine.trim();
132
- if (!line)
133
- continue;
134
- const m = EPISODE_TITLE_RE.exec(line);
135
- if (m && m[1]?.trim()) {
136
- const title = m[1].trim();
137
- // Defense in depth: don't pass through "技术值" titles like "第2集" / "ep_001" —
138
- // those would pass our regex but fail validateScript later, defeating the whole
139
- // "fix at source" guarantee. Catch them here.
140
- if (isTechnicalEpisodeTitle(title)) {
141
- throw new CliError("DRAFT FAILED: outline title looks technical, not a real 副标题", "Episode title looks like a placeholder.", {
142
- exitCode: EXIT_USAGE,
143
- required: [`first H1 in ${p} to contain a meaningful 副标题 (not "第N集" / "ep_001" / pure digits)`],
144
- received: [`extracted: "${title}"`],
145
- nextSteps: [
146
- `Replace the H1 with something like "# 第 ${n} 集:开场撞奥特曼" — a real one-line plot hook.`,
147
- "Generic / technical titles trip publish-time schema validation; only real 副标题 keep the chain clean.",
148
- ],
149
- });
150
- }
151
- return title;
152
- }
153
- break; // First non-blank line must match; otherwise fail
154
- }
155
- throw new CliError("DRAFT FAILED: episode title not found in outline H1", "Episode title required.", {
156
- exitCode: EXIT_USAGE,
157
- required: [`first non-blank line of ${p} matching "# 第 N 集:副标题"`],
158
- received: [`first line: ${text.split(/\r?\n/).find((l) => l.trim()) ?? "<empty file>"}`],
159
- nextSteps: [
160
- `Add a heading as the first line, e.g. "# 第 ${n} 集:开场撞奥特曼".`,
161
- "Title goes into the assembled script.episodes[i].title; without it, publish would either fail validation or upload an unnamed episode.",
162
- ],
163
- });
164
- }
165
- function checkGatewayEnv(projectGroupOverride) {
166
- const debug = isDebug();
167
- const tick = (ok) => (ok ? "✓" : "✗ MISSING");
168
- const mode = resolveOutputMode().mode;
169
- // Local backend has no env preflight — the store auto-resolves a default
170
- // path (sibling `output/` next to workspace) and lazy-creates it. Any
171
- // permissions failure surfaces from LocalScriptOutputStore.fromEnv as a
172
- // standard SCRIPT_API_CONFIG_MISSING envelope, so no UX is lost.
173
- if (mode === "local") {
174
- return { ready: true, table: ` Output target ${tick(true)}`, missing: [] };
175
- }
176
- const gatewayUrl = (process.env.AWB_BASE_URL || "").trim();
177
- const accessKey = (process.env.LINGJING_AWB_ACCESS_KEY || "").trim();
178
- const projectGroup = (projectGroupOverride || process.env.SANDBOX_PROJECT_GROUP_NO || "").trim();
179
- // Public-facing labels by default. The DEBUG path keeps the original env-var
180
- // names so operators with SCRIPTCTL_DEBUG=1 still get a complete picture.
181
- const labels = debug
182
- ? {
183
- gateway: "AWB_BASE_URL",
184
- access: "LINGJING_AWB_ACCESS_KEY",
185
- project: "SANDBOX_PROJECT_GROUP_NO (or --project-group-no on publish)",
186
- }
187
- : {
188
- gateway: "gateway",
189
- access: "access credential",
190
- project: "project (or --project-group-no on publish)",
191
- };
192
- const missing = [];
193
- if (!gatewayUrl)
194
- missing.push(labels.gateway);
195
- if (!accessKey)
196
- missing.push(labels.access);
197
- if (!projectGroup)
198
- missing.push(labels.project);
199
- const table = [
200
- ` Gateway URL ${tick(Boolean(gatewayUrl))}${debug && gatewayUrl ? ` ${gatewayUrl}` : ""}`,
201
- ` Access credential ${tick(Boolean(accessKey))}`,
202
- ` Project identifier ${tick(Boolean(projectGroup))}${debug && projectGroup ? ` ${projectGroup}` : ""}`,
203
- ].join("\n");
204
- return { ready: missing.length === 0, table, missing };
205
- }
206
- // ---------------------------------------------------------------------------
207
- // Provider factory
208
- // ---------------------------------------------------------------------------
209
- function makeWriterProvider(name, model) {
210
- // episode draft 仅支持 gemini(生产)+ mock(测试)。Claude 走 direct init 等结构化抽取
211
- // 路径,不混进创作正文,避免 agent / 用户在写剧本时纠结模型选择。
212
- if (name === "gemini")
213
- return new GeminiProvider(model);
214
- if (name === "mock")
215
- return new MockProvider();
216
- throw new CliError("DRAFT FAILED: Unsupported provider", "Unsupported provider.", {
217
- exitCode: EXIT_USAGE,
218
- required: ["--provider: gemini (default) | mock (tests only)"],
219
- received: [`--provider: ${name}`],
220
- nextSteps: ["Omit --provider to use Gemini, or pass --provider mock for tests."],
221
- });
222
- }
223
- // ---------------------------------------------------------------------------
224
- // Option parsing
225
- // ---------------------------------------------------------------------------
226
- function requireEpisode(opts) {
227
- const arg = opts._args?.[0];
228
- if (!arg) {
229
- throw new CliError("USAGE ERROR: Missing episode number", "Missing episode number.", {
230
- exitCode: EXIT_USAGE,
231
- required: ["<n>"],
232
- received: ["no positional argument"],
233
- nextSteps: ["Pass the episode number, e.g. `scriptctl episode draft 1`."],
234
- });
235
- }
236
- const n = parseInt(arg, 10);
237
- if (!Number.isFinite(n) || n < 1) {
238
- throw new CliError("USAGE ERROR: Invalid episode number", "Invalid episode number.", {
239
- exitCode: EXIT_USAGE,
240
- required: ["positive integer episode number"],
241
- received: [arg],
242
- nextSteps: ["Pass a positive integer, e.g. `scriptctl episode draft 5`."],
243
- });
244
- }
245
- return n;
246
- }
247
- function parseRange(raw) {
248
- if (!raw) {
249
- throw new CliError("USAGE ERROR: --range required", "--range required.", {
250
- exitCode: EXIT_USAGE,
251
- required: ["--range a-b"],
252
- received: ["no --range option"],
253
- nextSteps: ["Pass --range 1-10."],
254
- });
255
- }
256
- const m = /^(\d+)-(\d+)$/.exec(raw.trim());
257
- if (!m) {
258
- throw new CliError("USAGE ERROR: Invalid --range format", "Invalid --range format.", {
259
- exitCode: EXIT_USAGE,
260
- required: ["--range a-b (e.g. 1-10)"],
261
- received: [`--range ${raw}`],
262
- nextSteps: ["Use --range 1-10."],
263
- });
264
- }
265
- const a = parseInt(m[1], 10);
266
- const b = parseInt(m[2], 10);
267
- if (a < 1 || b < a) {
268
- throw new CliError("USAGE ERROR: Invalid --range bounds", "Invalid --range bounds.", {
269
- exitCode: EXIT_USAGE,
270
- required: ["1 <= a <= b"],
271
- received: [`a=${a}, b=${b}`],
272
- nextSteps: ["Pass a range like --range 1-10 with a >= 1 and b >= a."],
273
- });
274
- }
275
- return [a, b];
276
- }
277
- // ---------------------------------------------------------------------------
278
- // History archiving
279
- // ---------------------------------------------------------------------------
280
- function archivePriorVersion(workspace, n) {
281
- const mdPath = epMdPath(workspace, n);
282
- if (!exists(mdPath))
283
- return null;
284
- const dir = epHistoryDir(workspace, n);
285
- fs.mkdirSync(dir, { recursive: true });
286
- let version = 1;
287
- while (fs.existsSync(path.join(dir, `ep${padded(n)}.v${version}.md`)))
288
- version++;
289
- const target = path.join(dir, `ep${padded(n)}.v${version}.md`);
290
- fs.copyFileSync(mdPath, target);
291
- return target;
292
- }
293
- // ---------------------------------------------------------------------------
294
- // Helpers
295
- // ---------------------------------------------------------------------------
296
- function loadRegistryAt(workspace) {
297
- const registryPath = defaultRegistryPath(workspace);
298
- return { registry: loadRegistry(registryPath), registryPath };
299
- }
300
- function ensureEpisodeDir(workspace) {
301
- fs.mkdirSync(episodeDir(workspace), { recursive: true });
302
- }
303
- function writeFragment(workspace, n, fragment, raw) {
304
- ensureEpisodeDir(workspace);
305
- writeText(epMdPath(workspace, n), raw);
306
- writeJson(epJsonPath(workspace, n), fragment);
307
- }
308
- function writeCollisionReport(workspace, n, reports) {
309
- ensureEpisodeDir(workspace);
310
- writeJson(epCollisionPath(workspace, n), { episode: n, generatedAt: new Date().toISOString(), collisions: reports });
311
- }
312
- function clearCollisionReport(workspace, n) {
313
- const p = epCollisionPath(workspace, n);
314
- if (exists(p))
315
- fs.rmSync(p, { force: true });
316
- }
317
- // ---------------------------------------------------------------------------
318
- // `episode draft` — internal phase helpers
319
- // ---------------------------------------------------------------------------
320
- function loadPromptTemplate(promptTemplatePath) {
321
- if (!promptTemplatePath)
322
- return DEFAULT_WRITING_PROMPT;
323
- if (!exists(promptTemplatePath)) {
324
- throw new CliError("DRAFT FAILED: prompt template not found", "Prompt template not found.", {
325
- exitCode: EXIT_INPUT,
326
- required: [promptTemplatePath],
327
- received: ["no file at that path"],
328
- nextSteps: ["Verify the --prompt-template path is correct, or omit it to use scriptctl's bundled default."],
329
- });
330
- }
331
- // Apply the same placeholder substitution the bundled prompt uses, so skill-supplied
332
- // templates can `<!-- MARKDOWN_BATCH_PROMPT_SPEC -->` to inherit the parser spec
333
- // and stay in sync as it evolves. Without this, custom templates that use the
334
- // placeholder ship the literal comment text to the LLM, which then has no idea
335
- // what spec markdown grammar to follow.
336
- return injectSpec(readText(promptTemplatePath));
337
- }
338
- function applyRegenIfRequested(workspace, episode, regen) {
339
- if (regen !== true)
340
- return;
341
- archivePriorVersion(workspace, episode);
342
- // Skip rollback when the episode is already committed (marker file present) —
343
- // those ids are authoritative and a re-draft will reuse them by name.
344
- if (!exists(epMarkerPath(workspace, episode))) {
345
- const { registry, registryPath } = loadRegistryAt(workspace);
346
- rollbackEpisode(registry, episode);
347
- saveRegistry(registryPath, registry);
348
- }
349
- }
350
- /**
351
- * Produce the raw spec markdown for this draft. Source is one of:
352
- * - --resume : read the existing ep<n>.md (skip provider)
353
- * - --from-md : read user-provided spec md (skip provider)
354
- * - otherwise : invoke provider; retry once with parser-feedback on parse failure
355
- */
356
- async function produceRawMd(opts, workspace, episode, episodeTitle) {
357
- if (opts.resume === true) {
358
- const mdPath = epMdPath(workspace, episode);
359
- if (!exists(mdPath)) {
360
- throw new CliError("DRAFT FAILED: --resume requires existing ep<n>.md", "Resume target missing.", {
361
- exitCode: EXIT_INPUT,
362
- required: [mdPath],
363
- received: ["no file at that path"],
364
- nextSteps: ["Run `scriptctl episode draft <n>` first to create the draft, or pass --from-md <path>."],
365
- });
366
- }
367
- return { rawMd: readText(mdPath) };
368
- }
369
- if (opts.from_md) {
370
- if (!exists(opts.from_md)) {
371
- throw new CliError("DRAFT FAILED: --from-md path not found", "Spec markdown not found.", {
372
- exitCode: EXIT_INPUT,
373
- required: [opts.from_md],
374
- received: ["no file at that path"],
375
- nextSteps: ["Pass --from-md pointing at a real .md file."],
376
- });
377
- }
378
- return { rawMd: readText(opts.from_md) };
379
- }
380
- // Provider path
381
- const promptTemplate = loadPromptTemplate(opts.prompt_template);
382
- applyRegenIfRequested(workspace, episode, opts.regen);
383
- const spec = resolveContextSpec(opts);
384
- let writerContext;
385
- try {
386
- writerContext = loadWriterContext({
387
- workspace,
388
- episode,
389
- outlineTemplate: spec.outlineTemplate,
390
- refs: spec.refs,
391
- });
392
- }
393
- catch (exc) {
394
- throw new CliError("DRAFT FAILED: cannot load writer context", "Cannot load writer context.", {
395
- exitCode: EXIT_INPUT,
396
- required: ["per-episode 集纲 file"],
397
- received: [String(exc.message ?? exc)],
398
- nextSteps: [
399
- "Pass --outline <path-or-template> (e.g. --outline workspace/episodes/ep{NN}.outline.md), or",
400
- `Place the outline at ${path.join(workspace, "episodes", `ep${padded(episode)}.outline.md`)}.`,
401
- ],
402
- });
403
- }
404
- const { registry } = loadRegistryAt(workspace);
405
- const provider = makeWriterProvider(opts.provider ?? DEFAULT_EPISODE_PROVIDER, opts.model);
406
- const maxTokens = opts.max_tokens ? parseInt(opts.max_tokens, 10) : undefined;
407
- const baseReq = { episode, context: writerContext, registry, promptTemplate };
408
- const first = await draftEpisode(provider, baseReq);
409
- try {
410
- parseMarkdownBatch(first.raw, { episode, title: episodeTitle }, { fragmentMode: true });
411
- return { rawMd: first.raw, composedPrompt: first.prompt };
412
- }
413
- catch (parseErr) {
414
- // One retry with parser feedback before giving up.
415
- const detail = parseErr instanceof CliError ? parseErr.received.join("\n") : String(parseErr);
416
- const retry = await draftEpisode(provider, { ...baseReq, parserErrorFeedback: detail, maxTokens });
417
- try {
418
- parseMarkdownBatch(retry.raw, { episode, title: episodeTitle }, { fragmentMode: true });
419
- return { rawMd: retry.raw, composedPrompt: retry.prompt };
420
- }
421
- catch (finalErr) {
422
- ensureEpisodeDir(workspace);
423
- archivePriorVersion(workspace, episode);
424
- writeText(epMdPath(workspace, episode), retry.raw);
425
- throw new CliError("DRAFT FAILED: provider output still invalid after retry", "Provider output still invalid after retry.", {
426
- exitCode: EXIT_RUNTIME,
427
- required: ["spec-md markdown that parseMarkdownBatch can ingest"],
428
- received: [finalErr instanceof CliError ? finalErr.received.join(" | ") : String(finalErr)],
429
- nextSteps: [
430
- `Inspect ${epMdPath(workspace, episode)} and re-run with --regen, or fix the outline / prompt and re-run.`,
431
- ],
432
- });
433
- }
434
- }
435
- }
436
- // ---------------------------------------------------------------------------
437
- // `episode draft`
438
- // ---------------------------------------------------------------------------
439
- export async function commandEpisodeDraft(opts) {
440
- const workspace = opts.workspace_path ?? "workspace";
441
- const episode = requireEpisode(opts);
442
- // Step 1: pull episode title from outline H1. Hard fail if missing — title is
443
- // mandatory for assembled publish and must come from a single canonical source
444
- // (the outline file the agent already writes), not patched in after upload. Honors
445
- // --outline so `--from-md` + non-default outline locations keep title and body in sync.
446
- const episodeTitle = extractEpisodeTitle(workspace, episode, opts.outline);
447
- // Step 2: clear leftover lint / collision reports from a previous attempt. Without
448
- // this, status command + agent inspections see stale data when a prior run wrote
449
- // them but the current run takes an early-exit branch that skips re-writing.
450
- const lintFile = epLintPath(workspace, episode);
451
- if (exists(lintFile))
452
- fs.rmSync(lintFile, { force: true });
453
- clearCollisionReport(workspace, episode);
454
- // Step 3: produce raw spec markdown (resume / from-md / provider with retry).
455
- const skipDraft = opts.resume === true || Boolean(opts.from_md);
456
- const { rawMd, composedPrompt } = await produceRawMd(opts, workspace, episode, episodeTitle);
457
- // Parse + collision-check (always runs, including --resume / --from-md). Title is the
458
- // extracted outline H1 — flows through to the assembled script's episodes[i].title.
459
- let fragment;
460
- try {
461
- fragment = parseMarkdownBatch(rawMd, { episode, title: episodeTitle }, { fragmentMode: true });
462
- }
463
- catch (exc) {
464
- ensureEpisodeDir(workspace);
465
- writeText(epMdPath(workspace, episode), rawMd);
466
- if (exc instanceof CliError)
467
- throw exc;
468
- throw new CliError("DRAFT FAILED: parse error", "Parse error.", {
469
- exitCode: EXIT_RUNTIME,
470
- required: ["spec-md markdown"],
471
- received: [String(exc)],
472
- nextSteps: [`Inspect ${epMdPath(workspace, episode)} and fix the markdown manually, then re-run with --resume.`],
473
- });
474
- }
475
- const { registry, registryPath } = loadRegistryAt(workspace);
476
- const fragmentLike = asFragment(fragment);
477
- // Don't mutate the registry yet — collision-check first
478
- const reports = detectCollisions(fragmentLike, registry, { episode });
479
- // Persist the fragment (md + json) regardless of collisions — so the agent can edit
480
- writeFragment(workspace, episode, fragment, rawMd);
481
- if (hasHardCollision(reports)) {
482
- writeCollisionReport(workspace, episode, reports);
483
- const summary = summarizeReports(reports);
484
- const sourceLabel = skipDraft ? (opts.resume === true ? "--resume" : "--from-md") : "Gemini draft";
485
- return [
486
- {
487
- title: `DRAFT BLOCKED: 资产冲突 (ep${padded(episode)})`,
488
- message: "Episode drafted but blocked by asset collisions; agent must resolve.",
489
- summary,
490
- artifacts: [epMdPath(workspace, episode), epJsonPath(workspace, episode), epCollisionPath(workspace, episode)],
491
- next: [
492
- `Read ${epCollisionPath(workspace, episode)} for structured collision details.`,
493
- `Edit ${epMdPath(workspace, episode)} per the resolution guidance, then re-run with --resume.`,
494
- ],
495
- episode,
496
- sourceLabel,
497
- },
498
- EXIT_NEEDS_AGENT,
499
- ];
500
- }
501
- // No hard collisions — register the fragment in the registry as uncommitted-pending-checks
502
- clearCollisionReport(workspace, episode);
503
- applyFragment(registry, fragmentLike, episode);
504
- saveRegistry(registryPath, registry);
505
- const softReports = reports.filter((r) => r.kind === "soft");
506
- // Step 2: lint (absorbed from old `episode preview`) — single-episode quality checks
507
- const lint = lintEpisodeStub(fragment);
508
- writeJson(epLintPath(workspace, episode), lint);
509
- if (lint.critical.length > 0) {
510
- // Roll back the registration so the next attempt isn't confused by half-committed state.
511
- rollbackEpisode(registry, episode);
512
- saveRegistry(registryPath, registry);
513
- return [
514
- {
515
- title: `DRAFT BLOCKED: lint critical (ep${padded(episode)})`,
516
- message: `Episode ${episode} drafted but lint critical findings block commit.`,
517
- issues: lint.critical.map((s) => `[critical] ${s}`),
518
- warnings: [...lint.major.map((s) => `[major] ${s}`), ...lint.minor.map((s) => `[minor] ${s}`)],
519
- artifacts: [epMdPath(workspace, episode), epJsonPath(workspace, episode), epLintPath(workspace, episode)],
520
- next: [
521
- `Fix the critical issues in ${epMdPath(workspace, episode)}, then re-run with \`scriptctl episode draft ${episode} --resume\` (skips Gemini).`,
522
- `Or regenerate from scratch: \`scriptctl episode draft ${episode} --regen\`.`,
523
- ],
524
- episode,
525
- },
526
- EXIT_NEEDS_AGENT,
527
- ];
528
- }
529
- // Step 3: assembled validation (absorbed from old `episode publish` end-gate, moved to source).
530
- // Cross-episode issues (missing actor refs, schema drift, meta worldview validity) surface
531
- // here — at the moment they were introduced, not after a long publish chain.
532
- const validation = runAssembledValidation(workspace, episode, fragment);
533
- if (validation.blocking) {
534
- rollbackEpisode(registry, episode);
535
- saveRegistry(registryPath, registry);
536
- return [
537
- {
538
- title: `DRAFT BLOCKED: assembled validation (ep${padded(episode)})`,
539
- message: `Episode ${episode} drafted but cross-episode validation has blocking issues.`,
540
- issues: summarizeIssues(validation.issues),
541
- artifacts: [epMdPath(workspace, episode), epJsonPath(workspace, episode)],
542
- next: [
543
- `Fix the issues above. If the issue is in episodes/meta.json (e.g. worldview placeholder), edit meta then re-run \`scriptctl episode draft ${episode} --resume\`.`,
544
- `If the issue is content in ep${padded(episode)}.md, edit it then re-run with --resume.`,
545
- ],
546
- episode,
547
- },
548
- EXIT_NEEDS_AGENT,
549
- ];
550
- }
551
- // Step 4: auto-commit (absorbed from old `episode commit`). Single command does everything;
552
- // no separate preview/commit dance — agent ran draft, it's locked in. The marker file
553
- // is the single source of truth for "this episode is committed"; the registry just
554
- // holds durable cross-episode facts.
555
- writeText(epMarkerPath(workspace, episode), JSON.stringify({
556
- episode,
557
- title: episodeTitle,
558
- committedAt: new Date().toISOString(),
559
- scenes: fragment["scenes"]?.length ?? 0,
560
- }, null, 2));
561
- const softWarnings = [
562
- ...softReports.map((r) => `soft collision on ${r.assetType} "${r.fragmentDeclaration.name}" vs ${r.registryEntry.id}`),
563
- ...lint.major.map((s) => `[lint major] ${s}`),
564
- ...lint.minor.map((s) => `[lint minor] ${s}`),
565
- ...(validation.passed ? [] : validation.issues.filter((it) => it && it["severity"] !== "blocking" && it["severity"] !== "error").map((it) => `[validation] ${strOfIssue(it)}`)),
566
- ];
567
- return [
568
- {
569
- title: `DRAFT OK (ep${padded(episode)}${episodeTitle ? `: ${episodeTitle}` : ""})`,
570
- message: `Episode ${episode} drafted, lint clean, validation clean, committed.`,
571
- summary: [
572
- `title: ${episodeTitle}`,
573
- `scenes: ${fragment["scenes"]?.length ?? 0}`,
574
- `actors+: ${fragment["actors"]?.length ?? 0} new this episode`,
575
- `lint: 0 critical${lint.major.length ? `, ${lint.major.length} major` : ""}${lint.minor.length ? `, ${lint.minor.length} minor` : ""}`,
576
- `collision: 0 hard${softReports.length ? `, ${softReports.length} soft` : ""}`,
577
- `validation: ${validation.passed ? "passed" : `${validation.issues.length} warning(s)`}`,
578
- ].join("\n"),
579
- artifacts: [epMdPath(workspace, episode), epJsonPath(workspace, episode), epMarkerPath(workspace, episode)],
580
- warnings: softWarnings,
581
- next: [
582
- `Continue: \`scriptctl episode draft ${episode + 1}\``,
583
- `Or publish all drafted: \`scriptctl episode publish [--dry-run]\``,
584
- ],
585
- episode,
586
- promptPreviewChars: composedPrompt ? composedPrompt.length : undefined,
587
- },
588
- EXIT_OK,
589
- ];
590
- }
591
- function strOfIssue(issue) {
592
- return `${issue["code"] ?? "issue"}: ${issue["summary"] ?? issue["repair_hint"] ?? ""}`;
593
- }
594
- function lintEpisodeStub(fragment) {
595
- // WS6 will replace this with the full lint_scriptize implementation. For now we
596
- // do a minimal structural sanity check so the preview output is non-empty and the
597
- // commit gate has *something* to enforce.
598
- const critical = [];
599
- const major = [];
600
- const minor = [];
601
- const scenes = fragment["scenes"] ?? [];
602
- if (scenes.length === 0)
603
- critical.push("no scenes parsed");
604
- let dialogueLines = 0;
605
- let actionLines = 0;
606
- for (const scene of scenes) {
607
- const actions = scene["actions"] ?? [];
608
- for (const a of actions) {
609
- const kind = String(a["type"] ?? "");
610
- if (kind === "dialogue")
611
- dialogueLines += 1;
612
- else if (kind === "action")
613
- actionLines += 1;
614
- // perf hard rule: 单人单次对白 ≤ 40
615
- if (kind === "dialogue") {
616
- const content = String(a["content"] ?? "");
617
- if (content.length > 40) {
618
- major.push(`dialogue exceeds 40 chars (${content.length}): "${content.slice(0, 30)}..."`);
619
- }
620
- }
621
- }
622
- }
623
- if (dialogueLines + actionLines > 0) {
624
- const ratio = dialogueLines / (dialogueLines + actionLines);
625
- if (ratio > 0.8)
626
- major.push(`dialogue/action ratio ${(ratio * 100).toFixed(0)}% > 80% (too talky)`);
627
- if (ratio < 0.3)
628
- major.push(`dialogue/action ratio ${(ratio * 100).toFixed(0)}% < 30% (too silent)`);
629
- }
630
- return { critical, major, minor };
631
- }
632
- // ---------------------------------------------------------------------------
633
- // `episode batch`
634
- // ---------------------------------------------------------------------------
635
- export async function commandEpisodeBatch(opts) {
636
- const [start, end] = parseRange(opts.range);
637
- // After the draft+preview+commit collapse, batch just loops draft. draft auto-commits
638
- // on success and self-blocks on failure — no separate preview / commit calls needed.
639
- const ok = [];
640
- const blocked = [];
641
- for (let n = start; n <= end; n++) {
642
- const draftOpts = { ...opts, _args: [String(n)] };
643
- try {
644
- const [, code] = await commandEpisodeDraft(draftOpts);
645
- if (code === EXIT_OK)
646
- ok.push(n);
647
- else
648
- blocked.push(n);
649
- }
650
- catch {
651
- blocked.push(n);
652
- }
653
- }
654
- return [
655
- {
656
- title: `BATCH ${start}-${end} ${blocked.length ? "PARTIAL" : "OK"}`,
657
- message: `${ok.length} episode(s) drafted+committed, ${blocked.length} blocked.`,
658
- summary: [
659
- `committed: [${ok.join(", ")}]`,
660
- `blocked: [${blocked.join(", ")}]`,
661
- ].join("\n"),
662
- next: blocked.length
663
- ? blocked.map((n) => `Resolve ep${padded(n)} (see workspace/episodes/ep${padded(n)}.collision.json or ep${padded(n)}.lint.json).`)
664
- : [`All drafted+committed. Publish: \`scriptctl episode publish [--dry-run]\``],
665
- },
666
- blocked.length === 0 ? EXIT_OK : EXIT_NEEDS_AGENT,
667
- ];
668
- }
669
- // ---------------------------------------------------------------------------
670
- // `episode spec` — expose the SPEC constant used by the bundled Gemini prompt,
671
- // so agents writing spec md by hand (for `draft --from-md` / `--resume`) read
672
- // from the same single source of truth.
673
- // ---------------------------------------------------------------------------
674
- export function commandEpisodeSpec(opts) {
675
- void opts;
676
- return [
677
- {
678
- title: "EPISODE SPEC: scriptctl spec markdown grammar",
679
- body: MARKDOWN_BATCH_PROMPT_SPEC,
680
- },
681
- EXIT_OK,
682
- ];
683
- }
684
- // ---------------------------------------------------------------------------
685
- // `episode workspace`
686
- // ---------------------------------------------------------------------------
687
- // Reports the state of the `episode` (from-scratch) draft workspace only. This
688
- // is NOT a "how many episodes are in the final script" query — for that, see
689
- // `scriptctl summary` / `scriptctl episodes`.
690
- export function commandEpisodeWorkspace(opts) {
691
- const workspace = opts.workspace_path ?? "workspace";
692
- const dir = episodeDir(workspace);
693
- if (!fs.existsSync(dir)) {
694
- return [
695
- {
696
- title: "EPISODE WORKSPACE (empty)",
697
- message: "No episode-path drafts in this workspace. This only reflects the `episode` (from-scratch) draft area — it does not mean there is no final script.",
698
- next: [
699
- "If you want to query an EXISTING final script (episode count, per-episode size, etc.), run: scriptctl summary",
700
- "If you want to start a from-scratch creative pipeline here, run: scriptctl episode draft 1",
701
- ],
702
- },
703
- EXIT_OK,
704
- ];
705
- }
706
- const entries = fs.readdirSync(dir);
707
- const rows = [];
708
- for (const name of entries) {
709
- const m = /^ep(\d+)\.md$/.exec(name);
710
- if (!m)
711
- continue;
712
- const n = parseInt(m[1], 10);
713
- const committed = exists(epMarkerPath(workspace, n));
714
- const lintExists = exists(epLintPath(workspace, n));
715
- const collisionPath = epCollisionPath(workspace, n);
716
- let collisions = null;
717
- if (exists(collisionPath)) {
718
- try {
719
- const data = readJson(collisionPath);
720
- collisions = Array.isArray(data.collisions) ? data.collisions.length : null;
721
- }
722
- catch {
723
- collisions = null;
724
- }
725
- }
726
- // After the draft+preview+commit collapse, an episode is either: drafted (md+json
727
- // written), drafted-but-blocked (collision/lint issue remains), or committed (passed
728
- // all gates and marker written).
729
- let state;
730
- if (committed)
731
- state = "committed";
732
- else if (collisions && collisions > 0)
733
- state = "blocked-collision";
734
- else if (lintExists)
735
- state = "blocked-lint";
736
- else
737
- state = "drafted";
738
- rows.push({ episode: n, state, collisions });
739
- }
740
- rows.sort((a, b) => a.episode - b.episode);
741
- return [
742
- {
743
- title: "EPISODE WORKSPACE",
744
- message: `${rows.length} episode-path draft(s) tracked.`,
745
- summary: rows
746
- .map((r) => `ep${padded(r.episode)} ${r.state}${r.collisions ? ` (${r.collisions} collision report(s))` : ""}`)
747
- .join("\n"),
748
- result: rows.map((r) => `ep${padded(r.episode)}=${r.state}`),
749
- },
750
- EXIT_OK,
751
- ];
752
- }
753
- function readScriptMeta(metaFile) {
754
- if (!exists(metaFile)) {
755
- throw new CliError("PUBLISH BLOCKED: meta.json missing", "Episode meta missing.", {
756
- exitCode: EXIT_INPUT,
757
- required: [metaFile],
758
- received: ["no file at that path"],
759
- nextSteps: [
760
- `Run \`scriptctl episode init\` to scaffold a meta.json template, or hand-create ${metaFile} with at least {"title": "<剧本标题>", "worldview": "现代", "style": "<风格描述>"}.`,
761
- `worldview must be one of the canonical values (see scriptctl docs); worldview_raw is optional free text.`,
762
- ],
763
- });
764
- }
765
- let meta;
766
- try {
767
- meta = readJson(metaFile);
768
- }
769
- catch (exc) {
770
- throw new CliError("PUBLISH BLOCKED: meta.json invalid JSON", "Episode meta invalid.", {
771
- exitCode: EXIT_INPUT,
772
- required: ["valid JSON"],
773
- received: [String(exc.message ?? exc)],
774
- nextSteps: [`Verify ${metaFile} parses as JSON.`],
775
- });
776
- }
777
- if (!meta.title || typeof meta.title !== "string") {
778
- throw new CliError("PUBLISH BLOCKED: meta.json missing title", "Missing title.", {
779
- exitCode: EXIT_INPUT,
780
- required: ["meta.title (string)"],
781
- received: [`title: ${JSON.stringify(meta.title)}`],
782
- nextSteps: [`Add a non-empty "title" to ${metaFile}.`],
783
- });
784
- }
785
- if (!meta.worldview || typeof meta.worldview !== "string") {
786
- throw new CliError("PUBLISH BLOCKED: meta.json missing worldview", "Missing worldview.", {
787
- exitCode: EXIT_INPUT,
788
- required: ["meta.worldview (string)"],
789
- received: [`worldview: ${JSON.stringify(meta.worldview)}`],
790
- nextSteps: [`Add a "worldview" value to ${metaFile} (e.g. "现代", "古风架空").`],
791
- });
792
- }
793
- return meta;
794
- }
795
- /**
796
- * Collect committed episode slots (have `.done-<NN>` marker) under `episodes/`.
797
- *
798
- * Episodes whose draft is on disk but blocked (collision / lint / validation) get
799
- * the ep<n>.json written, but no marker — those must NOT enter an assembled
800
- * script. This function gates on marker presence so `merge` only ever sees
801
- * episodes the agent has actually passed through draft's auto-commit step.
802
- */
803
- function scanCommittedEpisodes(workspace, from, to) {
804
- const dir = episodeDir(workspace);
805
- if (!fs.existsSync(dir)) {
806
- throw new CliError("MERGE BLOCKED: no drafted episodes", "No drafted episodes.", {
807
- exitCode: EXIT_INPUT,
808
- required: [dir],
809
- received: ["directory does not exist"],
810
- nextSteps: ["Run `scriptctl episode draft <n>` for at least one episode first."],
811
- });
812
- }
813
- const allOnDisk = fs.readdirSync(dir)
814
- .filter((name) => /^ep\d+\.json$/.test(name))
815
- .map((name) => {
816
- const m = /^ep(\d+)\.json$/.exec(name);
817
- return { name, n: parseInt(m[1], 10) };
818
- })
819
- .sort((a, b) => a.n - b.n);
820
- const slots = [];
821
- const episodes = [];
822
- const paths = [];
823
- const skippedNoMarker = [];
824
- for (const entry of allOnDisk) {
825
- if (from !== undefined && entry.n < from)
826
- continue;
827
- if (to !== undefined && entry.n > to)
828
- continue;
829
- if (!exists(epMarkerPath(workspace, entry.n))) {
830
- skippedNoMarker.push(entry.n);
831
- continue;
832
- }
833
- const slotPath = path.join(dir, entry.name);
834
- slots.push(readJson(slotPath));
835
- episodes.push(entry.n);
836
- paths.push({ episode: entry.n, path: slotPath });
837
- }
838
- if (slots.length === 0) {
839
- const detail = skippedNoMarker.length > 0
840
- ? `found ${allOnDisk.length} drafted slot(s) but none have a .done marker; blocked ep(s): [${skippedNoMarker.join(", ")}]`
841
- : `found ${allOnDisk.length} drafted slot(s), filtered to 0 by range ${from ?? "-"}..${to ?? "-"}`;
842
- throw new CliError("MERGE BLOCKED: no committed episodes in range", "No committed episodes.", {
843
- exitCode: EXIT_INPUT,
844
- required: ["at least one episode passed through draft auto-commit (.done-<NN> marker)"],
845
- received: [detail],
846
- nextSteps: skippedNoMarker.length > 0
847
- ? [`Resolve the blocked episodes (see ep<n>.collision.json / ep<n>.lint.json), re-run draft, then merge.`]
848
- : ["Widen --from / --to, or draft more episodes first."],
849
- });
850
- }
851
- return { slots, episodes, paths };
852
- }
853
- /**
854
- * Merge episode slots with meta + schema version. Single source of truth for
855
- * "what gets validated / uploaded" — both runAssembledValidation (draft-time) and
856
- * commandEpisodePublish call this, so their assembled shape stays in lock-step.
857
- */
858
- function assembleScript(slots, meta) {
859
- const assembled = mergeEpisodeResults(slots, meta.title);
860
- assembled["worldview"] = meta.worldview;
861
- assembled["worldview_raw"] = meta.worldview_raw ?? meta.worldview;
862
- if (typeof meta.style === "string")
863
- assembled["style"] = meta.style;
864
- assembled["version"] = SCRIPT_SCHEMA_VERSION;
865
- return assembled;
866
- }
867
- /**
868
- * Assemble episodes 1..n (drafted) + this fragment + meta → run validateScript.
869
- * Used at draft time so cross-episode issues surface immediately, not at publish.
870
- * If meta.json is missing / has placeholders, returns blocking with clear next step.
871
- */
872
- function runAssembledValidation(workspace, currentEpisode, currentFragment) {
873
- const metaFile = metaPath(workspace);
874
- let meta;
875
- try {
876
- meta = readScriptMeta(metaFile);
877
- }
878
- catch (exc) {
879
- if (!(exc instanceof CliError))
880
- throw exc;
881
- return {
882
- passed: false,
883
- blocking: true,
884
- issues: [
885
- {
886
- code: "META_MISSING_OR_INVALID",
887
- severity: "blocking",
888
- summary: exc.message,
889
- repair_hint: exc.nextSteps.join(" ") || "Run `scriptctl episode init`, then fill episodes/meta.json before drafting.",
890
- },
891
- ],
892
- };
893
- }
894
- // Gather all drafted episode JSONs (excluding current — current is in-memory)
895
- const dir = episodeDir(workspace);
896
- const slots = [];
897
- if (fs.existsSync(dir)) {
898
- for (const name of fs.readdirSync(dir)) {
899
- const m = /^ep(\d+)\.json$/.exec(name);
900
- if (!m)
901
- continue;
902
- const n = parseInt(m[1], 10);
903
- if (n === currentEpisode)
904
- continue;
905
- slots.push(readJson(path.join(dir, name)));
906
- }
907
- }
908
- slots.push(currentFragment);
909
- slots.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
910
- const assembled = assembleScript(slots, meta);
911
- const validation = validateScript(workspace, null, { requireSource: false, scriptData: assembled });
912
- const issues = validation["issues"] ?? [];
913
- const blocking = Boolean(validation["has_blocking"]) ||
914
- issues.some((it) => it && (it["severity"] === "blocking" || it["severity"] === "error"));
915
- return { passed: Boolean(validation["passed"]) && !blocking, blocking, issues };
916
- }
917
- function parseRangeNumber(raw, label) {
918
- if (raw === undefined)
919
- return undefined;
920
- const n = parseInt(raw, 10);
921
- if (Number.isNaN(n)) {
922
- throw new CliError(`USAGE ERROR: ${label} must be integer`, `Invalid ${label}.`, {
923
- exitCode: EXIT_USAGE, required: ["integer"], received: [raw], nextSteps: [`e.g. ${label} 1`],
924
- });
925
- }
926
- return n;
927
- }
928
- function shaOfScript(script) {
929
- return sha256Text(JSON.stringify(sortDeep(script)));
930
- }
931
- function shaOfFile(p) {
932
- return sha256Text(readText(p));
933
- }
934
- export async function commandEpisodeMerge(opts) {
935
- const workspace = opts.workspace_path ?? "workspace";
936
- const force = opts.force === true;
937
- const resolvedMetaPath = opts.meta_path ?? metaPath(workspace);
938
- const from = parseRangeNumber(opts.from, "--from");
939
- const to = parseRangeNumber(opts.to, "--to");
940
- // Local-only — no gateway env preflight. push handles that.
941
- const meta = readScriptMeta(resolvedMetaPath);
942
- const { slots, episodes, paths } = scanCommittedEpisodes(workspace, from, to);
943
- const script = assembleScript(slots, meta);
944
- // Fresh merge dir every run — prevents stale artifact / receipt from a previous run
945
- // masquerading as current state. Same pattern publish used for its staging dir.
946
- const dir = mergedDir(workspace);
947
- fs.rmSync(dir, { recursive: true, force: true });
948
- const writeMerged = () => {
949
- fs.mkdirSync(dir, { recursive: true });
950
- writeJson(mergedScriptPath(workspace), script);
951
- };
952
- // Defensive re-validation — draft already gated each episode through the same
953
- // validateScript, so this should never fail in normal flow. It catches the case
954
- // where an agent hand-edited ep<n>.json after auto-commit.
955
- const validation = validateScript(workspace, null, { requireSource: false, scriptData: script });
956
- const issues = validation["issues"] ?? [];
957
- const blockingOrError = Boolean(validation["has_blocking"]) ||
958
- issues.some((it) => it && (it["severity"] === "blocking" || it["severity"] === "error"));
959
- if (!validation["passed"] && (!force || blockingOrError)) {
960
- // Persist the assembled script so the agent has something to inspect, but do NOT
961
- // write a receipt — push would otherwise see "ok" and ship a broken script.
962
- writeMerged();
963
- const title = force
964
- ? "MERGE BLOCKED: Validation errors require repair"
965
- : "MERGE BLOCKED: Validation needs agent repair";
966
- return [
967
- {
968
- title,
969
- message: "Assembled script failed schema validation; merge artifact saved but no receipt written (push will refuse).",
970
- result: [
971
- `Assembled script saved to ${mergedScriptPath(workspace)} for inspection.`,
972
- `Episodes considered: [${episodes.join(", ")}]`,
973
- ],
974
- issues: summarizeIssues(issues),
975
- artifacts: [mergedScriptPath(workspace)],
976
- next: [
977
- "Inspect the issues above; re-draft offending episode(s) via `scriptctl episode draft <n> --regen`, or hand-edit the corresponding ep<n>.md and `--resume`, then re-run `scriptctl episode merge`.",
978
- ],
979
- },
980
- EXIT_NEEDS_AGENT,
981
- ];
982
- }
983
- // Passed → persist artifact + receipt. Hashes lock the assembled output to the
984
- // exact set of input files; push will re-verify these before uploading.
985
- writeMerged();
986
- const receipt = {
987
- mergedAt: new Date().toISOString(),
988
- scriptSha256: shaOfScript(script),
989
- metaPath: path.relative(workspace, resolvedMetaPath),
990
- metaSha256: shaOfFile(resolvedMetaPath),
991
- episodes: paths.map((p) => ({
992
- episode: p.episode,
993
- path: path.relative(workspace, p.path),
994
- sha256: shaOfFile(p.path),
995
- })),
996
- validationPassed: Boolean(validation["passed"]),
997
- validationWarnings: issues.length,
998
- };
999
- writeJson(mergeReceiptPath(workspace), receipt);
1000
- return [
1001
- {
1002
- title: `MERGE OK (${episodes.length} episode${episodes.length === 1 ? "" : "s"})`,
1003
- message: `Assembled + validated locally. Run \`scriptctl episode push\` to upload.`,
1004
- summary: [
1005
- `episodes: [${episodes.join(", ")}]`,
1006
- `script: ${mergedScriptPath(workspace)}`,
1007
- `receipt: ${mergeReceiptPath(workspace)}`,
1008
- `script sha: ${receipt.scriptSha256.slice(0, 16)}...`,
1009
- validation["passed"] ? "validation: passed" : `validation: ${issues.length} non-blocking warning(s)`,
1010
- ].join("\n"),
1011
- artifacts: [mergedScriptPath(workspace), mergeReceiptPath(workspace)],
1012
- warnings: validation["passed"] ? [] : summarizeIssues(issues),
1013
- next: [
1014
- "Inspect the merged script; if good, upload with `scriptctl episode push`.",
1015
- "If you re-draft any episode after this point, re-run `scriptctl episode merge` — push refuses stale receipts.",
1016
- ],
1017
- },
1018
- EXIT_OK,
1019
- ];
1020
- }
1021
- export async function commandEpisodePush(opts) {
1022
- const workspace = opts.workspace_path ?? "workspace";
1023
- // Step 1: env preflight — push is the only command that talks to the
1024
- // output store. Wording adapts to whichever backend is active without
1025
- // revealing the mode concept to the caller.
1026
- const env = checkGatewayEnv(opts.project_group_no);
1027
- if (!env.ready) {
1028
- const isLocal = resolveOutputMode().mode === "local";
1029
- return [
1030
- {
1031
- title: isLocal
1032
- ? "PUSH BLOCKED: output target not configured"
1033
- : "PUSH BLOCKED: gateway env missing",
1034
- message: isLocal
1035
- ? "Cannot upload — output target is not configured."
1036
- : "Cannot upload without gateway credentials.",
1037
- summary: env.table,
1038
- result: env.missing.map((v) => `missing: ${v}`),
1039
- next: [
1040
- "Run `scriptctl doctor` to identify which configuration is missing.",
1041
- ],
1042
- },
1043
- EXIT_INPUT,
1044
- ];
1045
- }
1046
- // Step 2: merge artifact + receipt must exist (no implicit on-the-fly merge — push
1047
- // is purely a uploader, all assembly decisions live in `episode merge`).
1048
- const scriptFile = mergedScriptPath(workspace);
1049
- const receiptFile = mergeReceiptPath(workspace);
1050
- if (!exists(scriptFile) || !exists(receiptFile)) {
1051
- return [
1052
- {
1053
- title: "PUSH BLOCKED: merge artifact missing",
1054
- message: "Push requires a fresh `episode merge` artifact + receipt.",
1055
- result: [
1056
- `expected: ${scriptFile} (${exists(scriptFile) ? "found" : "missing"})`,
1057
- `expected: ${receiptFile} (${exists(receiptFile) ? "found" : "missing"})`,
1058
- ],
1059
- next: ["Run `scriptctl episode merge` first."],
1060
- },
1061
- EXIT_INPUT,
1062
- ];
1063
- }
1064
- let receipt;
1065
- try {
1066
- receipt = readJson(receiptFile);
1067
- }
1068
- catch (exc) {
1069
- return [
1070
- {
1071
- title: "PUSH BLOCKED: receipt invalid JSON",
1072
- message: "merge-receipt.json could not be parsed.",
1073
- result: [String(exc.message ?? exc)],
1074
- next: ["Run `scriptctl episode merge` to regenerate the receipt."],
1075
- },
1076
- EXIT_INPUT,
1077
- ];
1078
- }
1079
- // Step 3: triple hash check. Catches:
1080
- // (a) agent edited .merged/script.json after merge
1081
- // (b) agent edited meta.json after merge
1082
- // (c) agent re-drafted some ep<n>.json after merge but didn't re-merge
1083
- // Any drift → refuse + ask user to re-merge.
1084
- const driftIssues = [];
1085
- const scriptOnDisk = readJson(scriptFile);
1086
- if (shaOfScript(scriptOnDisk) !== receipt.scriptSha256) {
1087
- driftIssues.push("merged/script.json hash mismatch (script was edited after merge)");
1088
- }
1089
- const resolvedMetaPath = path.join(workspace, receipt.metaPath);
1090
- if (!exists(resolvedMetaPath)) {
1091
- driftIssues.push(`meta file missing: ${receipt.metaPath}`);
1092
- }
1093
- else if (shaOfFile(resolvedMetaPath) !== receipt.metaSha256) {
1094
- driftIssues.push(`meta.json hash mismatch (${receipt.metaPath} changed after merge)`);
1095
- }
1096
- for (const epRec of receipt.episodes) {
1097
- const p = path.join(workspace, epRec.path);
1098
- if (!exists(p)) {
1099
- driftIssues.push(`ep${padded(epRec.episode)} file missing: ${epRec.path}`);
1100
- continue;
1101
- }
1102
- if (shaOfFile(p) !== epRec.sha256) {
1103
- driftIssues.push(`ep${padded(epRec.episode)} hash mismatch (${epRec.path} changed after merge)`);
1104
- }
1105
- }
1106
- if (driftIssues.length > 0) {
1107
- return [
1108
- {
1109
- title: "PUSH BLOCKED: merge receipt stale",
1110
- message: "One or more inputs changed after merge; push refuses to upload an inconsistent snapshot.",
1111
- result: driftIssues,
1112
- next: ["Run `scriptctl episode merge` to refresh the artifact + receipt, then re-run `episode push`."],
1113
- },
1114
- EXIT_NEEDS_AGENT,
1115
- ];
1116
- }
1117
- // Step 4: upload the assembled script that merge produced. requestId derives from
1118
- // the receipt hash so a re-pushed identical snapshot is idempotent on the gateway side.
1119
- const client = scriptOutputClient(opts);
1120
- const baseRevision = await currentRevisionOrZero(client);
1121
- const requestId = (opts.request_id ?? "").trim() || `scriptctl-episode-push:${receipt.scriptSha256}`;
1122
- let replaceRes;
1123
- try {
1124
- replaceRes = await client.replaceScript({
1125
- requestId,
1126
- baseRevision,
1127
- script: scriptOnDisk,
1128
- source: "ctl",
1129
- });
1130
- }
1131
- catch (exc) {
1132
- if (exc instanceof ScriptOutputApiError) {
1133
- throw apiErrorToCli("PUSH BLOCKED: gateway write failed", exc);
1134
- }
1135
- throw exc;
1136
- }
1137
- const revision = Number(replaceRes["revision"] ?? 0);
1138
- // Persist validation outcome on gateway side too (non-fatal — the local receipt
1139
- // is authoritative for what was validated locally).
1140
- try {
1141
- await client.saveValidationResult({
1142
- revision,
1143
- validationStatus: receipt.validationPassed ? "passed" : "warning",
1144
- validationSummary: { passed: receipt.validationPassed, warnings: receipt.validationWarnings },
1145
- issues: [],
1146
- });
1147
- }
1148
- catch {
1149
- /* non-fatal */
1150
- }
1151
- // Push receipt lives next to merge receipt — single dir is the whole upload story.
1152
- writeJson(pushReceiptPath(workspace), {
1153
- pushedAt: new Date().toISOString(),
1154
- episodes: receipt.episodes.map((e) => e.episode),
1155
- revision,
1156
- requestId,
1157
- scriptSha256: receipt.scriptSha256,
1158
- validationPassed: receipt.validationPassed,
1159
- });
1160
- const eps = receipt.episodes.map((e) => e.episode);
1161
- return [
1162
- {
1163
- title: `PUSH OK (${eps.length} episode${eps.length === 1 ? "" : "s"})`,
1164
- message: `Uploaded episodes [${eps.join(", ")}] to gateway, revision ${revision}.`,
1165
- summary: [
1166
- `episodes: [${eps.join(", ")}]`,
1167
- `revision: ${revision}`,
1168
- `request_id: ${requestId}`,
1169
- receipt.validationPassed ? "validation: passed" : `validation: ${receipt.validationWarnings} non-blocking issue(s)`,
1170
- ].join("\n"),
1171
- artifacts: [pushReceiptPath(workspace), mergedScriptPath(workspace)],
1172
- next: ["Inspect on gateway. To push a new snapshot: re-run `episode merge` (after any upstream edit), then `episode push`."],
1173
- },
1174
- EXIT_OK,
1175
- ];
1176
- }
1177
- // ---------------------------------------------------------------------------
1178
- // `episode init`
1179
- // ---------------------------------------------------------------------------
1180
- const META_TEMPLATE = {
1181
- title: "TODO 请填写剧本标题",
1182
- worldview: "TODO 请填写世界观(现代 / 古代历史 / 古风架空 / 西幻架空 / 赛博朋克 / 星际时代 / 欧洲中世纪 / 上古时代 / 近代民国 之一)",
1183
- worldview_raw: "",
1184
- style: "TODO 请填写风格 / 题材标签(例:都市复仇 / 仙侠虐恋 / ...)",
1185
- };
1186
- export function commandEpisodeInit(opts) {
1187
- const workspace = opts.workspace_path ?? "workspace";
1188
- const force = opts.force === true;
1189
- const dir = episodeDir(workspace);
1190
- const metaFile = metaPath(workspace);
1191
- fs.mkdirSync(dir, { recursive: true });
1192
- const existed = exists(metaFile);
1193
- const overwrite = existed && force;
1194
- if (!existed || overwrite) {
1195
- writeJson(metaFile, META_TEMPLATE);
1196
- }
1197
- // Output-store env preflight — informational at init time (init is fully
1198
- // local; we don't block on missing env vars because the agent may not need
1199
- // them until publish day). Heading adapts to whichever backend will be used
1200
- // without naming the mode concept.
1201
- const env = checkGatewayEnv();
1202
- const readinessHeading = resolveOutputMode().mode === "local"
1203
- ? "Output target readiness:"
1204
- : "Gateway readiness:";
1205
- const gatewayLines = env.ready
1206
- ? ["", readinessHeading, env.table, " → ready for `episode publish` whenever you are."]
1207
- : [
1208
- "",
1209
- readinessHeading,
1210
- env.table,
1211
- " ⚠ Set the missing vars before `episode publish` (init doesn't need them).",
1212
- ];
1213
- const next = [];
1214
- if (existed && !force) {
1215
- next.push(`${metaFile} already exists and was not modified. Pass --force to overwrite with template.`);
1216
- }
1217
- else if (overwrite) {
1218
- next.push(`1. Re-fill title / worldview / style in ${metaFile} (just overwritten with TODO placeholders).`);
1219
- }
1220
- else {
1221
- next.push(`1. Fill in title / worldview / style in ${metaFile}.`);
1222
- }
1223
- next.push(`2. Prepare per-episode outlines at ${dir}/ep<NN>.outline.md (first line must be \`# 第 N 集:副标题\`).`);
1224
- next.push(`3. Run \`scriptctl episode draft 1\` (default provider: gemini; output auto-committed when validation passes).`);
1225
- next.push(`4. When all drafted: \`scriptctl episode publish [--dry-run]\``);
1226
- const titleSuffix = existed && !force
1227
- ? "INIT SKIPPED: meta.json already exists"
1228
- : existed
1229
- ? "INIT OK: meta.json overwritten with template"
1230
- : "INIT OK: episode workspace ready";
1231
- return [
1232
- {
1233
- title: titleSuffix,
1234
- message: `${dir}/ and ${metaFile} are ready.${env.ready ? " Gateway env present." : " Gateway env partial (see summary)."}`,
1235
- summary: gatewayLines.join("\n").trim(),
1236
- artifacts: [metaFile],
1237
- next,
1238
- },
1239
- EXIT_OK,
1240
- ];
1241
- }
1242
- //# sourceMappingURL=episode.js.map