@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.
- package/README.md +72 -0
- package/dist/cli.js +309 -396
- package/dist/cli.js.map +1 -1
- package/dist/common.d.ts +9 -0
- package/dist/common.js.map +1 -1
- package/dist/domain/asset-registry.d.ts +141 -0
- package/dist/domain/asset-registry.js +318 -0
- package/dist/domain/asset-registry.js.map +1 -0
- package/dist/domain/collision-detector.d.ts +83 -0
- package/dist/domain/collision-detector.js +248 -0
- package/dist/domain/collision-detector.js.map +1 -0
- package/dist/domain/direct-core.d.ts +13 -1
- package/dist/domain/direct-core.js +19 -6
- package/dist/domain/direct-core.js.map +1 -1
- package/dist/domain/script-core.d.ts +11 -0
- package/dist/domain/script-core.js +34 -19
- package/dist/domain/script-core.js.map +1 -1
- package/dist/help-text.js +336 -4
- package/dist/help-text.js.map +1 -1
- package/dist/infra/converters.js +21 -7
- package/dist/infra/converters.js.map +1 -1
- package/dist/infra/default-writing-prompt.d.ts +31 -0
- package/dist/infra/default-writing-prompt.js +50 -0
- package/dist/infra/default-writing-prompt.js.map +1 -0
- package/dist/infra/default-writing-prompt.md +115 -0
- package/dist/infra/gemini-writer.d.ts +107 -0
- package/dist/infra/gemini-writer.js +207 -0
- package/dist/infra/gemini-writer.js.map +1 -0
- package/dist/infra/providers.d.ts +36 -0
- package/dist/infra/providers.js +186 -2
- package/dist/infra/providers.js.map +1 -1
- package/dist/output.js +26 -9
- package/dist/output.js.map +1 -1
- package/dist/usecases/episode.d.ts +48 -0
- package/dist/usecases/episode.js +1209 -0
- package/dist/usecases/episode.js.map +1 -0
- package/dist/usecases/script.d.ts +6 -2
- package/dist/usecases/script.js +49 -5
- package/dist/usecases/script.js.map +1 -1
- 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
|