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