@lingjingai/scriptctl 0.11.4 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +10 -3
- package/dist/cli.js.map +1 -1
- package/dist/domain/direct-core.js +6 -3
- package/dist/domain/direct-core.js.map +1 -1
- package/dist/domain/script-core.d.ts +1 -0
- package/dist/domain/script-core.js +11 -2
- package/dist/domain/script-core.js.map +1 -1
- package/dist/help-text.js +0 -3
- package/dist/help-text.js.map +1 -1
- package/dist/infra/providers.js +16 -0
- package/dist/infra/providers.js.map +1 -1
- package/dist/usecases/direct.d.ts +1 -3
- package/dist/usecases/direct.js +255 -801
- package/dist/usecases/direct.js.map +1 -1
- package/dist/usecases/parse.d.ts +15 -0
- package/dist/usecases/parse.js +324 -0
- package/dist/usecases/parse.js.map +1 -0
- package/package.json +1 -1
package/dist/usecases/direct.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_CONCURRENCY, DEFAULT_MODEL, DEFAULT_PROVIDER,
|
|
4
|
-
import { compactBatchResult, compactEpisodeResult, buildBatchPlan, buildEpisodePlan, enrichEpisodePlanTitles, extractBatchWithRecovery, mergeEpisodeResults, normalizeEpisodeResult, normalizeInt,
|
|
3
|
+
import { CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_CONCURRENCY, DEFAULT_MODEL, DEFAULT_PROVIDER, EXIT_INPUT, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, REVIEW_TARGETS, SUPPORTED_EXTS, deletePath, directDir, exists, fmtId, readJson, readText, writeJson, } from "../common.js";
|
|
4
|
+
import { compactBatchResult, compactEpisodeResult, buildBatchPlan, buildEpisodePlan, enrichEpisodePlanTitles, extractBatchWithRecovery, mergeEpisodeResults, normalizeEpisodeResult, normalizeInt, uniqueAdd, validateEpisodeExtractionQuality, _md_push_asset, curateScriptAssets, applyMetadataToScript, } from "../domain/direct-core.js";
|
|
5
5
|
import { validateScript } from "../domain/script-core.js";
|
|
6
|
-
import { ScriptOutputApiError } from "../infra/script-output-store.js";
|
|
7
|
-
import { apiErrorToCli, currentRevisionOrZero, scriptOutputClient, sortDeep } from "./script.js";
|
|
8
6
|
import { makeProvider } from "../infra/providers.js";
|
|
9
7
|
import { makeSourceManifest, prepareSource, } from "../infra/converters.js";
|
|
10
8
|
function strOf(v) {
|
|
@@ -25,6 +23,12 @@ function checkpointTimestamp() {
|
|
|
25
23
|
const now = new Date();
|
|
26
24
|
return now.toISOString().replace(/\.\d+Z$/, "Z");
|
|
27
25
|
}
|
|
26
|
+
function parseIntList(v) {
|
|
27
|
+
return strOf(v).split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !Number.isNaN(n));
|
|
28
|
+
}
|
|
29
|
+
function parseStrList(v) {
|
|
30
|
+
return strOf(v).split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
31
|
+
}
|
|
28
32
|
// ---------------------------------------------------------------------------
|
|
29
33
|
// run_state.json
|
|
30
34
|
// ---------------------------------------------------------------------------
|
|
@@ -56,18 +60,6 @@ export function readRunState(workspace) {
|
|
|
56
60
|
return {};
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
|
-
function failureSignature(items) {
|
|
60
|
-
if (!isList(items))
|
|
61
|
-
return [];
|
|
62
|
-
const out = [];
|
|
63
|
-
for (const item of items) {
|
|
64
|
-
const s = strOf(item).trim();
|
|
65
|
-
if (s)
|
|
66
|
-
out.push(s);
|
|
67
|
-
}
|
|
68
|
-
out.sort();
|
|
69
|
-
return out;
|
|
70
|
-
}
|
|
71
63
|
export function addInspectedTarget(workspace, target) {
|
|
72
64
|
const state = readRunState(workspace);
|
|
73
65
|
const targets = [];
|
|
@@ -149,9 +141,6 @@ function episodeErrorPath(dir, ep) {
|
|
|
149
141
|
function episodeResultKey(ep) {
|
|
150
142
|
return `ep_${pad3(Number(ep["episode"]))}`;
|
|
151
143
|
}
|
|
152
|
-
function episodeResultsIndexPath(dir) {
|
|
153
|
-
return path.join(dir, "index.json");
|
|
154
|
-
}
|
|
155
144
|
function batchResultKey(batch) {
|
|
156
145
|
const bid = strOf(batch["batch_id"]).trim();
|
|
157
146
|
if (bid)
|
|
@@ -167,9 +156,6 @@ function batchMarkdownPath(dir, batch) {
|
|
|
167
156
|
function batchErrorPath(dir, batch) {
|
|
168
157
|
return path.join(dir, `${batchResultKey(batch)}.error.json`);
|
|
169
158
|
}
|
|
170
|
-
function batchResultsIndexPath(dir) {
|
|
171
|
-
return path.join(dir, "index.json");
|
|
172
|
-
}
|
|
173
159
|
function persistBatchResult(dir, batch, result) {
|
|
174
160
|
const rawMd = result["_raw_markdown"];
|
|
175
161
|
delete result["_raw_markdown"];
|
|
@@ -183,263 +169,55 @@ function persistBatchResult(dir, batch, result) {
|
|
|
183
169
|
deletePath(mdPath);
|
|
184
170
|
}
|
|
185
171
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (!isDict(data["batches"]))
|
|
200
|
-
data["batches"] = {};
|
|
201
|
-
if (!("version" in data))
|
|
202
|
-
data["version"] = 1;
|
|
203
|
-
return data;
|
|
204
|
-
}
|
|
205
|
-
function writeBatchResultsIndex(dir, index) {
|
|
206
|
-
writeJson(batchResultsIndexPath(dir), index);
|
|
207
|
-
}
|
|
208
|
-
function updateBatchResultMetadata(dir, batch, providerName, model) {
|
|
209
|
-
const index = readBatchResultsIndex(dir);
|
|
210
|
-
const batches = index["batches"] ?? {};
|
|
211
|
-
batches[batchResultKey(batch)] = {
|
|
212
|
-
episode: Number(batch["episode"]),
|
|
213
|
-
part: Number(batch["part"]),
|
|
214
|
-
provider: providerName,
|
|
215
|
-
model,
|
|
216
|
-
extracted_at: checkpointTimestamp(),
|
|
217
|
-
};
|
|
218
|
-
index["batches"] = batches;
|
|
219
|
-
writeBatchResultsIndex(dir, index);
|
|
220
|
-
}
|
|
221
|
-
function removeBatchResultMetadata(dir, batch) {
|
|
222
|
-
const index = readBatchResultsIndex(dir);
|
|
223
|
-
const batches = index["batches"] ?? {};
|
|
224
|
-
const key = batchResultKey(batch);
|
|
225
|
-
if (key in batches) {
|
|
226
|
-
delete batches[key];
|
|
227
|
-
index["batches"] = batches;
|
|
228
|
-
writeBatchResultsIndex(dir, index);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
function readEpisodeResultsIndex(dir) {
|
|
232
|
-
const p = episodeResultsIndexPath(dir);
|
|
233
|
-
if (!exists(p))
|
|
234
|
-
return { version: 1, episodes: {} };
|
|
235
|
-
let data;
|
|
236
|
-
try {
|
|
237
|
-
data = readJson(p);
|
|
238
|
-
}
|
|
239
|
-
catch {
|
|
240
|
-
return { version: 1, episodes: {} };
|
|
241
|
-
}
|
|
242
|
-
if (!isDict(data))
|
|
243
|
-
return { version: 1, episodes: {} };
|
|
244
|
-
if (!isDict(data["episodes"]))
|
|
245
|
-
data["episodes"] = {};
|
|
246
|
-
if (!("version" in data))
|
|
247
|
-
data["version"] = 1;
|
|
248
|
-
return data;
|
|
249
|
-
}
|
|
250
|
-
function writeEpisodeResultsIndex(dir, index) {
|
|
251
|
-
writeJson(episodeResultsIndexPath(dir), index);
|
|
252
|
-
}
|
|
253
|
-
function updateEpisodeResultMetadata(dir, ep, providerName, model) {
|
|
254
|
-
const index = readEpisodeResultsIndex(dir);
|
|
255
|
-
const episodes = index["episodes"] ?? {};
|
|
256
|
-
episodes[episodeResultKey(ep)] = {
|
|
257
|
-
provider: providerName,
|
|
258
|
-
model,
|
|
259
|
-
extracted_at: checkpointTimestamp(),
|
|
260
|
-
};
|
|
261
|
-
index["episodes"] = episodes;
|
|
262
|
-
writeEpisodeResultsIndex(dir, index);
|
|
263
|
-
}
|
|
264
|
-
function removeEpisodeResultMetadata(dir, ep) {
|
|
265
|
-
const index = readEpisodeResultsIndex(dir);
|
|
266
|
-
const episodes = index["episodes"] ?? {};
|
|
267
|
-
const key = episodeResultKey(ep);
|
|
268
|
-
if (key in episodes) {
|
|
269
|
-
delete episodes[key];
|
|
270
|
-
index["episodes"] = episodes;
|
|
271
|
-
writeEpisodeResultsIndex(dir, index);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
function compactResultHasMultiRefs(data) {
|
|
275
|
-
for (const scene of asList(data["sc"])) {
|
|
276
|
-
if (!isDict(scene))
|
|
277
|
-
continue;
|
|
278
|
-
for (const action of asList(scene["a"])) {
|
|
279
|
-
if (!isDict(action))
|
|
280
|
-
continue;
|
|
281
|
-
const refs = action["r"];
|
|
282
|
-
if (isList(refs) && refs.length > 1)
|
|
283
|
-
return true;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
return false;
|
|
287
|
-
}
|
|
288
|
-
export function initCheckpoint(sourceText, plan) {
|
|
289
|
-
const planText = JSON.stringify(plan, checkpointReplacer());
|
|
290
|
-
return {
|
|
291
|
-
contract_version: DIRECT_CONTRACT_VERSION,
|
|
292
|
-
source_sha256: sha256Text(sourceText),
|
|
293
|
-
episode_plan_sha256: sha256Text(planText),
|
|
294
|
-
total_episodes: Number(plan["total_episodes"] ?? asList(plan["episodes"]).length),
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
export function initBatchCheckpoint(sourceText, batchPlan) {
|
|
298
|
-
const planText = JSON.stringify(batchPlan, checkpointReplacer());
|
|
299
|
-
return {
|
|
300
|
-
contract_version: DIRECT_CONTRACT_VERSION,
|
|
301
|
-
source_sha256: sha256Text(sourceText),
|
|
302
|
-
batch_plan_sha256: sha256Text(planText),
|
|
303
|
-
total_batches: Number(batchPlan["total_batches"] ?? asList(batchPlan["batches"]).length),
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
// Title fields are LLM-mutated downstream by enrichEpisodePlanTitles, so they
|
|
307
|
-
// must be excluded from checkpoint hashes — otherwise every rerun gets a fresh
|
|
308
|
-
// SHA, the previous checkpoint never matches, and the whole batch pipeline
|
|
309
|
-
// re-extracts from scratch.
|
|
310
|
-
const CHECKPOINT_UNSTABLE_KEYS = new Set(["title", "generated_title", "title_status", "title_source"]);
|
|
311
|
-
function checkpointReplacer() {
|
|
312
|
-
// Python's json.dumps(sort_keys=True) sorts keys recursively. Replicate by walking and sorting.
|
|
313
|
-
return function (key, value) {
|
|
314
|
-
if (CHECKPOINT_UNSTABLE_KEYS.has(key))
|
|
315
|
-
return undefined;
|
|
316
|
-
if (isDict(value)) {
|
|
317
|
-
const sorted = {};
|
|
318
|
-
for (const k of Object.keys(value).sort()) {
|
|
319
|
-
if (CHECKPOINT_UNSTABLE_KEYS.has(k))
|
|
320
|
-
continue;
|
|
321
|
-
sorted[k] = value[k];
|
|
322
|
-
}
|
|
323
|
-
return sorted;
|
|
324
|
-
}
|
|
325
|
-
return value;
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
function checkpointSourceMatches(previous, current) {
|
|
329
|
-
if (!previous || Object.keys(previous).length === 0)
|
|
330
|
-
return false;
|
|
331
|
-
const keys = ["contract_version", "source_sha256", "episode_plan_sha256", "total_episodes"];
|
|
332
|
-
return keys.every((k) => previous[k] === current[k]);
|
|
333
|
-
}
|
|
334
|
-
function batchCheckpointMatches(previous, current) {
|
|
335
|
-
if (!previous || Object.keys(previous).length === 0)
|
|
336
|
-
return false;
|
|
337
|
-
const keys = ["contract_version", "source_sha256", "batch_plan_sha256", "total_batches"];
|
|
338
|
-
return keys.every((k) => previous[k] === current[k]);
|
|
339
|
-
}
|
|
340
|
-
function resetInitOutputs(dd) {
|
|
341
|
-
for (const dirname of ["episode_results", "batch_results"]) {
|
|
342
|
-
const target = path.join(dd, dirname);
|
|
343
|
-
if (exists(target))
|
|
344
|
-
deleteTree(target);
|
|
345
|
-
}
|
|
346
|
-
for (const name of ["script.initial.json", "validation.json", "batch_plan.json", "asset_curation.json", "asset_metadata.json"]) {
|
|
347
|
-
const p = path.join(dd, name);
|
|
348
|
-
if (exists(p))
|
|
349
|
-
deletePath(p);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
function resetBatchOutputs(dd) {
|
|
353
|
-
const batchResultsDir = path.join(dd, "batch_results");
|
|
354
|
-
if (exists(batchResultsDir))
|
|
355
|
-
deleteTree(batchResultsDir);
|
|
356
|
-
}
|
|
357
|
-
function loadCheckpointedEpisode(sourceText, episodeResultsDir, ep, providerName, model, previousProvider) {
|
|
358
|
-
const p = episodeResultPath(episodeResultsDir, ep);
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Per-batch result files ARE the state. A batch with `<key>.json` is done; with
|
|
174
|
+
// `<key>.error.json` it failed; with neither it is pending. Reuse = file
|
|
175
|
+
// presence — the agent drives what to (re)run via `direct status` + --episodes/
|
|
176
|
+
// --batches selection. No content hashes, no global checkpoint, no destructive
|
|
177
|
+
// reset: re-running a unit only ever touches that unit's own files, so the other
|
|
178
|
+
// episodes can never be lost.
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Read a persisted batch result (reused or freshly written) for assembly.
|
|
181
|
+
// Returns null if absent or unreadable, in which case the batch counts as
|
|
182
|
+
// not-done and will be (re)run.
|
|
183
|
+
function readBatchResult(dir, batch) {
|
|
184
|
+
const p = batchResultPath(dir, batch);
|
|
359
185
|
if (!exists(p))
|
|
360
186
|
return null;
|
|
361
|
-
let result;
|
|
362
187
|
try {
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (isDict(eps)) {
|
|
369
|
-
const entry = eps[episodeResultKey(ep)];
|
|
370
|
-
if (isDict(entry))
|
|
371
|
-
indexEntry = entry;
|
|
372
|
-
}
|
|
373
|
-
const resultProvider = strOf(metadata["provider"] || indexEntry["provider"] || previousProvider).trim();
|
|
374
|
-
if (providerName && resultProvider && resultProvider !== providerName) {
|
|
375
|
-
throw new Error(`checkpoint provider mismatch: ${resultProvider} != ${providerName}`);
|
|
376
|
-
}
|
|
377
|
-
result = normalizeEpisodeResult(data, ep);
|
|
378
|
-
validateEpisodeExtractionQuality(sourceText, ep, result);
|
|
379
|
-
if (!("sc" in data) || ["episode", "title", "source_span", "_scriptctl"].some((k) => k in data)) {
|
|
380
|
-
writeJson(p, compactEpisodeResult(result));
|
|
381
|
-
if (providerName && model)
|
|
382
|
-
updateEpisodeResultMetadata(episodeResultsDir, ep, providerName, model);
|
|
383
|
-
}
|
|
188
|
+
const result = normalizeEpisodeResult(readJson(p), batch);
|
|
189
|
+
result["_batch_id"] = batchResultKey(batch);
|
|
190
|
+
result["_batch_part"] = Number(batch["part"]);
|
|
191
|
+
result["_starts_inside_scene"] = Boolean(batch["starts_inside_scene"]);
|
|
192
|
+
return result;
|
|
384
193
|
}
|
|
385
194
|
catch {
|
|
386
|
-
try {
|
|
387
|
-
deletePath(p);
|
|
388
|
-
}
|
|
389
|
-
catch {
|
|
390
|
-
// ignore
|
|
391
|
-
}
|
|
392
|
-
removeEpisodeResultMetadata(episodeResultsDir, ep);
|
|
393
195
|
return null;
|
|
394
196
|
}
|
|
395
|
-
if (Number(result["episode"] ?? 0) !== Number(ep["episode"]))
|
|
396
|
-
return null;
|
|
397
|
-
if (JSON.stringify(result["source_span"]) !== JSON.stringify(ep["source_span"]))
|
|
398
|
-
return null;
|
|
399
|
-
return result;
|
|
400
197
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
indexEntry = entry;
|
|
415
|
-
}
|
|
416
|
-
const resultProvider = strOf(indexEntry["provider"] || previousProvider).trim();
|
|
417
|
-
if (providerName && resultProvider && resultProvider !== providerName) {
|
|
418
|
-
throw new Error(`checkpoint provider mismatch: ${resultProvider} != ${providerName}`);
|
|
419
|
-
}
|
|
420
|
-
result = normalizeEpisodeResult(data, batch);
|
|
421
|
-
validateBatchExtractionQuality(sourceText, batch, result);
|
|
422
|
-
if (!("sc" in data) || compactResultHasMultiRefs(data) || ["episode", "title", "source_span", "_scriptctl"].some((k) => k in data)) {
|
|
423
|
-
persistBatchResult(batchResultsDir, batch, result);
|
|
424
|
-
if (providerName && model)
|
|
425
|
-
updateBatchResultMetadata(batchResultsDir, batch, providerName, model);
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
catch {
|
|
429
|
-
try {
|
|
430
|
-
deletePath(p);
|
|
198
|
+
// Delete result/meta/error/markdown files whose unit key is no longer in the
|
|
199
|
+
// current plan (e.g. the source shed an episode). Pure function of the plan —
|
|
200
|
+
// it never inspects hashes, content, or run_state, so it can only remove units
|
|
201
|
+
// the plan no longer references. Also retires the legacy v3 `index.json`.
|
|
202
|
+
function gcOrphanUnits(dir, liveKeys) {
|
|
203
|
+
if (!exists(dir))
|
|
204
|
+
return [];
|
|
205
|
+
const removed = [];
|
|
206
|
+
for (const name of fs.readdirSync(dir)) {
|
|
207
|
+
if (name === "index.json") {
|
|
208
|
+
deletePath(path.join(dir, name));
|
|
209
|
+
removed.push(name);
|
|
210
|
+
continue;
|
|
431
211
|
}
|
|
432
|
-
|
|
433
|
-
|
|
212
|
+
const key = name.replace(/\.(meta\.json|error\.json|json|md)$/, "");
|
|
213
|
+
if (key === name)
|
|
214
|
+
continue; // not a recognized unit artifact
|
|
215
|
+
if (!liveKeys.has(key)) {
|
|
216
|
+
deletePath(path.join(dir, name));
|
|
217
|
+
removed.push(name);
|
|
434
218
|
}
|
|
435
|
-
removeBatchResultMetadata(batchResultsDir, batch);
|
|
436
|
-
return null;
|
|
437
219
|
}
|
|
438
|
-
|
|
439
|
-
return null;
|
|
440
|
-
if (JSON.stringify(result["source_span"]) !== JSON.stringify(batch["source_span"]))
|
|
441
|
-
return null;
|
|
442
|
-
return result;
|
|
220
|
+
return removed;
|
|
443
221
|
}
|
|
444
222
|
function mergeScene(target, source) {
|
|
445
223
|
if ((target["location_name"] === "" || target["location_name"] === "未知场景" || target["location_name"] === null || target["location_name"] === undefined) &&
|
|
@@ -532,31 +310,10 @@ async function providerExtractAssetCurationLocal(provider, sourceText, script) {
|
|
|
532
310
|
}
|
|
533
311
|
return {};
|
|
534
312
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
title: ep["title"],
|
|
540
|
-
source_span: ep["source_span"],
|
|
541
|
-
error_type: err?.name || "Error",
|
|
542
|
-
message: (err?.message || err?.name || "Error").slice(0, 500),
|
|
543
|
-
failed_at: checkpointTimestamp(),
|
|
544
|
-
};
|
|
545
|
-
if (exc instanceof CliError) {
|
|
546
|
-
if (exc.required.length > 0)
|
|
547
|
-
error["required"] = exc.required;
|
|
548
|
-
if (exc.received.length > 0)
|
|
549
|
-
error["received"] = exc.received;
|
|
550
|
-
if (exc.nextSteps.length > 0)
|
|
551
|
-
error["next"] = exc.nextSteps;
|
|
552
|
-
}
|
|
553
|
-
const resultPath = episodeResultPath(dir, ep);
|
|
554
|
-
if (exists(resultPath))
|
|
555
|
-
deletePath(resultPath);
|
|
556
|
-
removeEpisodeResultMetadata(dir, ep);
|
|
557
|
-
writeJson(episodeErrorPath(dir, ep), error);
|
|
558
|
-
return error;
|
|
559
|
-
}
|
|
313
|
+
// Record a batch failure: write `<key>.error.json` (which marks the batch as
|
|
314
|
+
// "tried and failed" → sticky on plain resume) and drop any stale result file.
|
|
315
|
+
// The error_code (e.g. PROVIDER_CONTENT_FILTERED) is surfaced so the agent can
|
|
316
|
+
// see WHY in `direct status` / `direct inspect` and decide whether to re-run.
|
|
560
317
|
function writeBatchFailure(dir, batch, exc) {
|
|
561
318
|
const err = exc;
|
|
562
319
|
const error = {
|
|
@@ -570,6 +327,8 @@ function writeBatchFailure(dir, batch, exc) {
|
|
|
570
327
|
failed_at: checkpointTimestamp(),
|
|
571
328
|
};
|
|
572
329
|
if (exc instanceof CliError) {
|
|
330
|
+
if (exc.errorCode)
|
|
331
|
+
error["error_code"] = exc.errorCode;
|
|
573
332
|
if (exc.required.length > 0)
|
|
574
333
|
error["required"] = exc.required;
|
|
575
334
|
if (exc.received.length > 0)
|
|
@@ -580,7 +339,6 @@ function writeBatchFailure(dir, batch, exc) {
|
|
|
580
339
|
const resultPath = batchResultPath(dir, batch);
|
|
581
340
|
if (exists(resultPath))
|
|
582
341
|
deletePath(resultPath);
|
|
583
|
-
removeBatchResultMetadata(dir, batch);
|
|
584
342
|
writeJson(batchErrorPath(dir, batch), error);
|
|
585
343
|
return error;
|
|
586
344
|
}
|
|
@@ -635,6 +393,14 @@ export async function commandInit(opts) {
|
|
|
635
393
|
const workspace = strOf(opts["workspace_path"] || "workspace");
|
|
636
394
|
const providerName = strOf(opts["provider"] || DEFAULT_PROVIDER);
|
|
637
395
|
const model = strOf(opts["model"] || process.env.SCRIPTCTL_ANTHROPIC_MODEL || DEFAULT_MODEL);
|
|
396
|
+
// Explicit selection: the agent decides what to (re)run. Default is "resume"
|
|
397
|
+
// — only batches never attempted run. --episodes / --batches force-rerun the
|
|
398
|
+
// named units, --retry-errors force-reruns everything currently errored, --all
|
|
399
|
+
// reruns everything. Forced units have their stale result/error deleted first.
|
|
400
|
+
const selEpisodes = new Set(parseIntList(opts["episodes"]));
|
|
401
|
+
const selBatches = new Set(parseStrList(opts["batches"]));
|
|
402
|
+
const retryErrors = Boolean(opts["retry_errors"]);
|
|
403
|
+
const runAll = Boolean(opts["all"]);
|
|
638
404
|
let concurrency;
|
|
639
405
|
try {
|
|
640
406
|
concurrency = parseInt(strOf(opts["concurrency"] || DEFAULT_CONCURRENCY), 10);
|
|
@@ -719,7 +485,6 @@ export async function commandInit(opts) {
|
|
|
719
485
|
}
|
|
720
486
|
const dd = directDir(workspace);
|
|
721
487
|
fs.mkdirSync(dd, { recursive: true });
|
|
722
|
-
const previousStateBeforeInit = readRunState(workspace);
|
|
723
488
|
updateRunState(workspace, {
|
|
724
489
|
status: "init_running",
|
|
725
490
|
command: "direct init",
|
|
@@ -832,17 +597,6 @@ export async function commandInit(opts) {
|
|
|
832
597
|
nextSteps: ["Inspect workspace/source.txt and episode_plan.json, then rerun init."],
|
|
833
598
|
});
|
|
834
599
|
}
|
|
835
|
-
const checkpoint = initCheckpoint(sourceText, plan);
|
|
836
|
-
const batchCheckpoint = initBatchCheckpoint(sourceText, batchPlan);
|
|
837
|
-
const previousState = previousStateBeforeInit;
|
|
838
|
-
const previousCheckpoint = isDict(previousState["checkpoint"]) ? previousState["checkpoint"] : {};
|
|
839
|
-
const previousBatchCheckpoint = isDict(previousState["batch_checkpoint"]) ? previousState["batch_checkpoint"] : {};
|
|
840
|
-
const checkpointReused = checkpointSourceMatches(previousCheckpoint, checkpoint);
|
|
841
|
-
const batchCheckpointReused = checkpointReused && batchCheckpointMatches(previousBatchCheckpoint, batchCheckpoint);
|
|
842
|
-
if (!checkpointReused)
|
|
843
|
-
resetInitOutputs(dd);
|
|
844
|
-
else if (!batchCheckpointReused)
|
|
845
|
-
resetBatchOutputs(dd);
|
|
846
600
|
writeJson(path.join(dd, "source_manifest.json"), manifest);
|
|
847
601
|
writeJson(path.join(dd, "episode_plan.json"), plan);
|
|
848
602
|
writeJson(path.join(dd, "batch_plan.json"), batchPlan);
|
|
@@ -850,24 +604,12 @@ export async function commandInit(opts) {
|
|
|
850
604
|
const batchResultsDir = path.join(dd, "batch_results");
|
|
851
605
|
fs.mkdirSync(episodeResultsDir, { recursive: true });
|
|
852
606
|
fs.mkdirSync(batchResultsDir, { recursive: true });
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
batch_checkpoint_reused: batchCheckpointReused,
|
|
860
|
-
batch_mode: batchMode,
|
|
861
|
-
batch_target_lines: batchTargetLines,
|
|
862
|
-
batch_max_chars: batchMaxChars,
|
|
863
|
-
batch_min_lines: batchMinLines,
|
|
864
|
-
episode_total: asList(plan["episodes"]).length,
|
|
865
|
-
batch_total: asList(batchPlan["batches"]).length,
|
|
866
|
-
});
|
|
867
|
-
const results = [];
|
|
868
|
-
const skipped = [];
|
|
869
|
-
let skippedEpisodeBatchCount = 0;
|
|
870
|
-
const pendingBatches = [];
|
|
607
|
+
// Non-destructive GC: drop result/meta/error/md files for units the current
|
|
608
|
+
// plan no longer references (e.g. the source shed an episode). Pure function
|
|
609
|
+
// of the plan — it never touches a unit the plan still references, and retires
|
|
610
|
+
// the legacy v3 index.json. There is no whole-directory reset any more.
|
|
611
|
+
gcOrphanUnits(episodeResultsDir, new Set(asList(plan["episodes"]).map((ep) => episodeResultKey(ep))));
|
|
612
|
+
gcOrphanUnits(batchResultsDir, new Set(asList(batchPlan["batches"]).map((b) => batchResultKey(b))));
|
|
871
613
|
const batchesByEpisode = new Map();
|
|
872
614
|
for (const batch of asList(batchPlan["batches"])) {
|
|
873
615
|
const epNum = Number(batch["episode"]);
|
|
@@ -875,65 +617,55 @@ export async function commandInit(opts) {
|
|
|
875
617
|
batchesByEpisode.set(epNum, []);
|
|
876
618
|
batchesByEpisode.get(epNum).push(batch);
|
|
877
619
|
}
|
|
878
|
-
const
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
620
|
+
const totalEpisodes = asList(plan["episodes"]).length;
|
|
621
|
+
const totalBatches = asList(batchPlan["batches"]).length;
|
|
622
|
+
// Decide which batches to run. Reuse = a result file is present; an error file
|
|
623
|
+
// makes a batch "sticky" (skipped on a plain rerun so a content filter can't
|
|
624
|
+
// loop). The agent overrides this with explicit selection.
|
|
625
|
+
const pending = [];
|
|
626
|
+
let reusedBatchCount = 0;
|
|
627
|
+
let stickyErrorCount = 0;
|
|
628
|
+
for (const batch of asList(batchPlan["batches"])) {
|
|
629
|
+
const epNum = Number(batch["episode"]);
|
|
630
|
+
const key = batchResultKey(batch);
|
|
631
|
+
const resultPath = batchResultPath(batchResultsDir, batch);
|
|
632
|
+
const errorPath = batchErrorPath(batchResultsDir, batch);
|
|
633
|
+
const forced = runAll || selEpisodes.has(epNum) || selBatches.has(key) || (retryErrors && exists(errorPath));
|
|
634
|
+
if (forced) {
|
|
635
|
+
if (exists(resultPath))
|
|
636
|
+
deletePath(resultPath);
|
|
637
|
+
if (exists(errorPath))
|
|
638
|
+
deletePath(errorPath);
|
|
639
|
+
pending.push(batch);
|
|
898
640
|
}
|
|
899
|
-
else {
|
|
900
|
-
|
|
641
|
+
else if (exists(resultPath)) {
|
|
642
|
+
reusedBatchCount++;
|
|
901
643
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
const skippedBatches = [];
|
|
905
|
-
const pending = [];
|
|
906
|
-
for (const batch of pendingBatches) {
|
|
907
|
-
const cachedBatch = batchCheckpointReused
|
|
908
|
-
? loadCheckpointedBatch(sourceText, batchResultsDir, batch, providerName, model, previousProvider)
|
|
909
|
-
: null;
|
|
910
|
-
if (cachedBatch !== null) {
|
|
911
|
-
cachedBatch["_batch_id"] = batchResultKey(batch);
|
|
912
|
-
cachedBatch["_batch_part"] = Number(batch["part"]);
|
|
913
|
-
cachedBatch["_starts_inside_scene"] = Boolean(batch["starts_inside_scene"]);
|
|
914
|
-
batchResults.push(cachedBatch);
|
|
915
|
-
skippedBatches.push(batchResultKey(batch));
|
|
644
|
+
else if (exists(errorPath)) {
|
|
645
|
+
stickyErrorCount++; // tried and failed; left alone until explicitly re-run
|
|
916
646
|
}
|
|
917
647
|
else {
|
|
918
|
-
pending.push(batch);
|
|
648
|
+
pending.push(batch); // never attempted
|
|
919
649
|
}
|
|
920
650
|
}
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
651
|
+
updateRunState(workspace, {
|
|
652
|
+
status: "init_running",
|
|
653
|
+
init_stage: "batch_extract",
|
|
654
|
+
batch_mode: batchMode,
|
|
655
|
+
batch_target_lines: batchTargetLines,
|
|
656
|
+
batch_max_chars: batchMaxChars,
|
|
657
|
+
batch_min_lines: batchMinLines,
|
|
658
|
+
episode_total: totalEpisodes,
|
|
659
|
+
batch_total: totalBatches,
|
|
924
660
|
});
|
|
661
|
+
const failures = [];
|
|
662
|
+
const outcomes = await pMapWithConcurrency(pending, concurrency, async (batch) => extractBatchWithRecovery(provider, sourceText, batch));
|
|
925
663
|
for (let i = 0; i < outcomes.length; i++) {
|
|
926
664
|
const outcome = outcomes[i];
|
|
927
665
|
const batch = pending[i];
|
|
928
|
-
const errorPath = batchErrorPath(batchResultsDir, batch);
|
|
929
666
|
if (outcome.ok) {
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
result["_batch_part"] = Number(batch["part"]);
|
|
933
|
-
result["_starts_inside_scene"] = Boolean(batch["starts_inside_scene"]);
|
|
934
|
-
batchResults.push(result);
|
|
935
|
-
persistBatchResult(batchResultsDir, batch, result);
|
|
936
|
-
updateBatchResultMetadata(batchResultsDir, batch, providerName, model);
|
|
667
|
+
persistBatchResult(batchResultsDir, batch, outcome.value);
|
|
668
|
+
const errorPath = batchErrorPath(batchResultsDir, batch);
|
|
937
669
|
if (exists(errorPath))
|
|
938
670
|
deletePath(errorPath);
|
|
939
671
|
}
|
|
@@ -941,185 +673,93 @@ export async function commandInit(opts) {
|
|
|
941
673
|
failures.push(writeBatchFailure(batchResultsDir, batch, outcome.error));
|
|
942
674
|
}
|
|
943
675
|
}
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
failures.sort((a, b) => {
|
|
953
|
-
const ea = Number(a["episode"] ?? 0);
|
|
954
|
-
const eb = Number(b["episode"] ?? 0);
|
|
955
|
-
if (ea !== eb)
|
|
956
|
-
return ea - eb;
|
|
957
|
-
return Number(a["part"] ?? 0) - Number(b["part"] ?? 0);
|
|
958
|
-
});
|
|
959
|
-
const completedBatches = skippedEpisodeBatchCount + batchResults.length;
|
|
960
|
-
if (failures.length > 0) {
|
|
961
|
-
const failedEpisodes = [...new Set(failures.map((it) => Number(it["episode"])))].sort((a, b) => a - b);
|
|
962
|
-
const failedBatches = failures.map((it) => strOf(it["batch_id"]));
|
|
963
|
-
const currentFailureSignature = failureSignature(failedBatches);
|
|
964
|
-
const previousFailureSignature = failureSignature(previousState["failed_batches"]);
|
|
965
|
-
const sameFailuresRepeated = checkpointReused &&
|
|
966
|
-
batchCheckpointReused &&
|
|
967
|
-
currentFailureSignature.length > 0 &&
|
|
968
|
-
currentFailureSignature.length === previousFailureSignature.length &&
|
|
969
|
-
currentFailureSignature.every((v, idx) => v === previousFailureSignature[idx]) &&
|
|
970
|
-
["init_incomplete", "init_stalled"].includes(strOf(previousState["status"]));
|
|
971
|
-
const previousFailureStreak = normalizeInt(previousState["failure_streak"], 0);
|
|
972
|
-
const failureStreak = sameFailuresRepeated ? previousFailureStreak + 1 : 1;
|
|
973
|
-
const failureTitle = sameFailuresRepeated
|
|
974
|
-
? "INIT STALLED: Same batches keep failing"
|
|
975
|
-
: "INIT INCOMPLETE: Batch extraction failed";
|
|
976
|
-
const nextSteps = sameFailuresRepeated
|
|
977
|
-
? [
|
|
978
|
-
"Run direct inspect --target issue to read failed batch details.",
|
|
979
|
-
"Do not rerun the same init command again until source, batch options, provider, or failed content has changed.",
|
|
980
|
-
]
|
|
981
|
-
: [
|
|
982
|
-
"Run direct inspect --target issue to review failed batches.",
|
|
983
|
-
"Rerun the same init once if failures look transient; completed checkpoints will be reused.",
|
|
984
|
-
];
|
|
985
|
-
const failedEpisodeSet = new Set(failedEpisodes);
|
|
986
|
-
const skippedSet = new Set(skipped);
|
|
987
|
-
const batchResultsByEpisode = new Map();
|
|
988
|
-
for (const result of batchResults) {
|
|
989
|
-
const ep = Number(result["episode"] ?? 0);
|
|
990
|
-
if (!batchResultsByEpisode.has(ep))
|
|
991
|
-
batchResultsByEpisode.set(ep, []);
|
|
992
|
-
batchResultsByEpisode.get(ep).push(result);
|
|
993
|
-
}
|
|
676
|
+
const doneBatches = reusedBatchCount + outcomes.filter((o) => o.ok).length;
|
|
677
|
+
const erroredBatches = stickyErrorCount + failures.length;
|
|
678
|
+
// Assemble every episode whose batches are all present on disk (reused +
|
|
679
|
+
// freshly extracted, read uniformly from disk). Incomplete episodes are
|
|
680
|
+
// reported; assembling them is left to an explicit re-run.
|
|
681
|
+
const results = [];
|
|
682
|
+
const incompleteEpisodes = [];
|
|
683
|
+
try {
|
|
994
684
|
for (const episode of asList(plan["episodes"])) {
|
|
995
685
|
const episodeNum = Number(episode["episode"]);
|
|
996
|
-
|
|
686
|
+
const epBatches = batchesByEpisode.get(episodeNum) ?? [];
|
|
687
|
+
const batchResults = epBatches.map((b) => readBatchResult(batchResultsDir, b)).filter((r) => r !== null);
|
|
688
|
+
if (epBatches.length === 0 || batchResults.length !== epBatches.length) {
|
|
689
|
+
incompleteEpisodes.push(episodeNum);
|
|
997
690
|
continue;
|
|
998
|
-
const expectedBatches = (batchesByEpisode.get(episodeNum) ?? []).length;
|
|
999
|
-
if (expectedBatches && (batchResultsByEpisode.get(episodeNum) ?? []).length === expectedBatches) {
|
|
1000
|
-
const result = mergeBatchResultsForEpisode(episode, batchResultsByEpisode.get(episodeNum) ?? []);
|
|
1001
|
-
validateEpisodeExtractionQuality(sourceText, episode, result);
|
|
1002
|
-
results.push(result);
|
|
1003
|
-
writeJson(episodeResultPath(episodeResultsDir, episode), compactEpisodeResult(result));
|
|
1004
|
-
updateEpisodeResultMetadata(episodeResultsDir, episode, providerName, model);
|
|
1005
691
|
}
|
|
692
|
+
const merged = mergeBatchResultsForEpisode(episode, batchResults);
|
|
693
|
+
validateEpisodeExtractionQuality(sourceText, episode, merged);
|
|
694
|
+
results.push(merged);
|
|
695
|
+
writeJson(episodeResultPath(episodeResultsDir, episode), compactEpisodeResult(merged));
|
|
696
|
+
const errPath = episodeErrorPath(episodeResultsDir, episode);
|
|
697
|
+
if (exists(errPath))
|
|
698
|
+
deletePath(errPath);
|
|
1006
699
|
}
|
|
700
|
+
}
|
|
701
|
+
catch (exc) {
|
|
702
|
+
const e = exc;
|
|
703
|
+
throw initFailedReport(workspace, {
|
|
704
|
+
title: "INIT FAILED: Episode merge failed",
|
|
705
|
+
stage: "episode_merge",
|
|
706
|
+
required: ["batch_results/*.json that can merge into episode_results/*.json"],
|
|
707
|
+
received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
|
|
708
|
+
nextSteps: ["Rerun init; completed batches are reused and episode merge will retry."],
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
|
|
712
|
+
incompleteEpisodes.sort((a, b) => a - b);
|
|
713
|
+
if (incompleteEpisodes.length > 0) {
|
|
1007
714
|
updateRunState(workspace, {
|
|
1008
|
-
status:
|
|
715
|
+
status: "init_incomplete",
|
|
1009
716
|
init_stage: "batch_extract",
|
|
1010
|
-
|
|
1011
|
-
batch_checkpoint: batchCheckpoint,
|
|
1012
|
-
episode_total: asList(plan["episodes"]).length,
|
|
717
|
+
episode_total: totalEpisodes,
|
|
1013
718
|
episode_completed: results.length,
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
batch_reused: skippedEpisodeBatchCount + skippedBatches.length,
|
|
1020
|
-
batch_failed: failures.length,
|
|
1021
|
-
failed_batches: failedBatches,
|
|
1022
|
-
failure_signature: currentFailureSignature,
|
|
1023
|
-
failure_streak: failureStreak,
|
|
1024
|
-
last_error: { title: failureTitle, failed_at: checkpointTimestamp() },
|
|
1025
|
-
exportable: false,
|
|
719
|
+
incomplete_episodes: incompleteEpisodes,
|
|
720
|
+
batch_total: totalBatches,
|
|
721
|
+
batch_completed: doneBatches,
|
|
722
|
+
batch_failed: erroredBatches,
|
|
723
|
+
last_error: { title: "INIT INCOMPLETE: Some episodes have unextracted batches", failed_at: checkpointTimestamp() },
|
|
1026
724
|
});
|
|
1027
|
-
const issues = failures.slice(0, 5).map((it) => `${it["batch_id"]} episode ${it["episode"]} part ${it["part"]}: ${it["error_type"]} - ${it["message"]}`);
|
|
1028
725
|
const report = {
|
|
1029
|
-
title:
|
|
726
|
+
title: "INIT INCOMPLETE: Some episodes have unextracted batches",
|
|
1030
727
|
result: [
|
|
1031
|
-
`episodes
|
|
1032
|
-
`
|
|
1033
|
-
`
|
|
1034
|
-
`failed episodes: ${failedEpisodes.length}`,
|
|
1035
|
-
`batches: ${completedBatches}/${asList(batchPlan["batches"]).length} completed, ${failures.length} failed`,
|
|
728
|
+
`episodes: ${results.length}/${totalEpisodes} complete`,
|
|
729
|
+
`incomplete episodes: ${incompleteEpisodes.join(", ")}`,
|
|
730
|
+
`batches: ${doneBatches}/${totalBatches} done, ${erroredBatches} error`,
|
|
1036
731
|
`provider: ${providerName}`,
|
|
1037
732
|
],
|
|
1038
733
|
artifacts: [
|
|
1039
734
|
path.join(workspace, "source.txt"),
|
|
1040
|
-
path.join(dd, "source_manifest.json"),
|
|
1041
735
|
path.join(dd, "episode_plan.json"),
|
|
1042
736
|
path.join(dd, "batch_plan.json"),
|
|
1043
737
|
batchResultsDir,
|
|
1044
738
|
episodeResultsDir,
|
|
1045
739
|
path.join(dd, "run_state.json"),
|
|
1046
740
|
],
|
|
1047
|
-
|
|
1048
|
-
|
|
741
|
+
next: [
|
|
742
|
+
"Run `direct status` to see per-episode batch state.",
|
|
743
|
+
"Re-run one episode with `direct init --episodes <n>`, one batch with `--batches <key>`, or all errors with `--retry-errors`.",
|
|
744
|
+
"Errored batches are left untouched on a plain rerun; select them to retry.",
|
|
745
|
+
],
|
|
1049
746
|
};
|
|
1050
747
|
return [report, EXIT_RUNTIME];
|
|
1051
748
|
}
|
|
1052
749
|
updateRunState(workspace, {
|
|
1053
750
|
status: "init_running",
|
|
1054
751
|
init_stage: "episode_merge",
|
|
1055
|
-
|
|
1056
|
-
batch_checkpoint: batchCheckpoint,
|
|
1057
|
-
episode_total: asList(plan["episodes"]).length,
|
|
752
|
+
episode_total: totalEpisodes,
|
|
1058
753
|
episode_completed: results.length,
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
batch_completed: completedBatches,
|
|
1064
|
-
batch_reused: skippedEpisodeBatchCount + skippedBatches.length,
|
|
1065
|
-
batch_failed: 0,
|
|
1066
|
-
failed_batches: [],
|
|
1067
|
-
failure_signature: [],
|
|
1068
|
-
failure_streak: 0,
|
|
754
|
+
incomplete_episodes: [],
|
|
755
|
+
batch_total: totalBatches,
|
|
756
|
+
batch_completed: doneBatches,
|
|
757
|
+
batch_failed: erroredBatches,
|
|
1069
758
|
last_error: null,
|
|
1070
759
|
});
|
|
1071
|
-
for (const dir of [batchResultsDir, episodeResultsDir]) {
|
|
1072
|
-
if (!exists(dir))
|
|
1073
|
-
continue;
|
|
1074
|
-
for (const name of fs.readdirSync(dir)) {
|
|
1075
|
-
if (name.endsWith(".error.json")) {
|
|
1076
|
-
try {
|
|
1077
|
-
deletePath(path.join(dir, name));
|
|
1078
|
-
}
|
|
1079
|
-
catch {
|
|
1080
|
-
// ignore
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
try {
|
|
1086
|
-
const batchResultsByEpisode = new Map();
|
|
1087
|
-
for (const result of batchResults) {
|
|
1088
|
-
const ep = Number(result["episode"] ?? 0);
|
|
1089
|
-
if (!batchResultsByEpisode.has(ep))
|
|
1090
|
-
batchResultsByEpisode.set(ep, []);
|
|
1091
|
-
batchResultsByEpisode.get(ep).push(result);
|
|
1092
|
-
}
|
|
1093
|
-
const skippedSet = new Set(skipped);
|
|
1094
|
-
for (const episode of asList(plan["episodes"])) {
|
|
1095
|
-
const episodeNum = Number(episode["episode"]);
|
|
1096
|
-
if (skippedSet.has(episodeNum))
|
|
1097
|
-
continue;
|
|
1098
|
-
const result = mergeBatchResultsForEpisode(episode, batchResultsByEpisode.get(episodeNum) ?? []);
|
|
1099
|
-
validateEpisodeExtractionQuality(sourceText, episode, result);
|
|
1100
|
-
results.push(result);
|
|
1101
|
-
writeJson(episodeResultPath(episodeResultsDir, episode), compactEpisodeResult(result));
|
|
1102
|
-
updateEpisodeResultMetadata(episodeResultsDir, episode, providerName, model);
|
|
1103
|
-
const errorPath = episodeErrorPath(episodeResultsDir, episode);
|
|
1104
|
-
if (exists(errorPath))
|
|
1105
|
-
deletePath(errorPath);
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
catch (exc) {
|
|
1109
|
-
const e = exc;
|
|
1110
|
-
throw initFailedReport(workspace, {
|
|
1111
|
-
title: "INIT FAILED: Episode merge failed",
|
|
1112
|
-
stage: "episode_merge",
|
|
1113
|
-
required: ["complete batch_results/*.json that can merge into episode_results/*.json"],
|
|
1114
|
-
received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
|
|
1115
|
-
nextSteps: ["Rerun init; completed batch checkpoints will be reused and episode merge will retry."],
|
|
1116
|
-
updates: { checkpoint, batch_checkpoint: batchCheckpoint, batch_completed: completedBatches },
|
|
1117
|
-
});
|
|
1118
|
-
}
|
|
1119
|
-
results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
|
|
1120
760
|
let script;
|
|
1121
761
|
try {
|
|
1122
|
-
updateRunState(workspace, { status: "init_running", init_stage: "script_merge"
|
|
762
|
+
updateRunState(workspace, { status: "init_running", init_stage: "script_merge" });
|
|
1123
763
|
script = mergeEpisodeResults(results, strOf(info["projectName"]) || path.basename(source, path.extname(source)));
|
|
1124
764
|
}
|
|
1125
765
|
catch (exc) {
|
|
@@ -1130,11 +770,11 @@ export async function commandInit(opts) {
|
|
|
1130
770
|
required: ["complete episode_results/*.json"],
|
|
1131
771
|
received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
|
|
1132
772
|
nextSteps: ["Rerun init; completed episode extraction checkpoints will be reused and merge will retry."],
|
|
1133
|
-
updates: {
|
|
773
|
+
updates: { episode_completed: results.length },
|
|
1134
774
|
});
|
|
1135
775
|
}
|
|
1136
776
|
try {
|
|
1137
|
-
updateRunState(workspace, { status: "init_running", init_stage: "asset_curation"
|
|
777
|
+
updateRunState(workspace, { status: "init_running", init_stage: "asset_curation" });
|
|
1138
778
|
const rawCuration = await providerExtractAssetCurationLocal(provider, sourceText, script);
|
|
1139
779
|
const curation = curateScriptAssets(script, rawCuration);
|
|
1140
780
|
writeJson(path.join(dd, "asset_curation.json"), curation);
|
|
@@ -1148,7 +788,7 @@ export async function commandInit(opts) {
|
|
|
1148
788
|
required: exc.required.length > 0 ? exc.required : ["asset curation JSON matching final script contract"],
|
|
1149
789
|
received: exc.received.length > 0 ? exc.received : [String(exc.message).slice(0, 160)],
|
|
1150
790
|
nextSteps: exc.nextSteps.length > 0 ? exc.nextSteps : ["Rerun init; extraction checkpoints will be reused and asset curation will retry."],
|
|
1151
|
-
updates: {
|
|
791
|
+
updates: { episode_completed: results.length },
|
|
1152
792
|
});
|
|
1153
793
|
}
|
|
1154
794
|
const e = exc;
|
|
@@ -1158,11 +798,11 @@ export async function commandInit(opts) {
|
|
|
1158
798
|
required: ["provider location merge decisions and deterministic asset reuse curation"],
|
|
1159
799
|
received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
|
|
1160
800
|
nextSteps: ["Rerun init; extraction checkpoints will be reused and asset curation will retry."],
|
|
1161
|
-
updates: {
|
|
801
|
+
updates: { episode_completed: results.length },
|
|
1162
802
|
});
|
|
1163
803
|
}
|
|
1164
804
|
try {
|
|
1165
|
-
updateRunState(workspace, { status: "init_running", init_stage: "metadata_extract"
|
|
805
|
+
updateRunState(workspace, { status: "init_running", init_stage: "metadata_extract" });
|
|
1166
806
|
let metadata = provider.extractMetadata ? await provider.extractMetadata(sourceText, script) : {};
|
|
1167
807
|
if (!isDict(metadata))
|
|
1168
808
|
metadata = {};
|
|
@@ -1178,7 +818,7 @@ export async function commandInit(opts) {
|
|
|
1178
818
|
required: exc.required.length > 0 ? exc.required : ["metadata JSON matching final script contract"],
|
|
1179
819
|
received: exc.received.length > 0 ? exc.received : [String(exc.message).slice(0, 160)],
|
|
1180
820
|
nextSteps: exc.nextSteps.length > 0 ? exc.nextSteps : ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
|
|
1181
|
-
updates: {
|
|
821
|
+
updates: { episode_completed: results.length },
|
|
1182
822
|
});
|
|
1183
823
|
}
|
|
1184
824
|
const e = exc;
|
|
@@ -1188,12 +828,12 @@ export async function commandInit(opts) {
|
|
|
1188
828
|
required: ["provider metadata for worldview, role_type, and asset descriptions"],
|
|
1189
829
|
received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
|
|
1190
830
|
nextSteps: ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
|
|
1191
|
-
updates: {
|
|
831
|
+
updates: { episode_completed: results.length },
|
|
1192
832
|
});
|
|
1193
833
|
}
|
|
1194
834
|
const scriptPath = path.join(dd, "script.initial.json");
|
|
1195
835
|
writeJson(scriptPath, script);
|
|
1196
|
-
updateRunState(workspace, { status: "init_running", init_stage: "validate"
|
|
836
|
+
updateRunState(workspace, { status: "init_running", init_stage: "validate" });
|
|
1197
837
|
let validation;
|
|
1198
838
|
try {
|
|
1199
839
|
validation = validateScript(workspace, scriptPath);
|
|
@@ -1206,7 +846,7 @@ export async function commandInit(opts) {
|
|
|
1206
846
|
required: ["script.initial.json that can be validated"],
|
|
1207
847
|
received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
|
|
1208
848
|
nextSteps: ["Rerun init to retry validation, or inspect script.initial.json if the failure persists."],
|
|
1209
|
-
updates: {
|
|
849
|
+
updates: { script_path: scriptPath },
|
|
1210
850
|
});
|
|
1211
851
|
}
|
|
1212
852
|
const passed = Boolean(validation["passed"]);
|
|
@@ -1215,10 +855,6 @@ export async function commandInit(opts) {
|
|
|
1215
855
|
status,
|
|
1216
856
|
command: "direct init",
|
|
1217
857
|
init_stage: "complete",
|
|
1218
|
-
checkpoint,
|
|
1219
|
-
batch_checkpoint: batchCheckpoint,
|
|
1220
|
-
checkpoint_reused: checkpointReused,
|
|
1221
|
-
batch_checkpoint_reused: batchCheckpointReused,
|
|
1222
858
|
provider: providerName,
|
|
1223
859
|
model,
|
|
1224
860
|
concurrency,
|
|
@@ -1229,24 +865,18 @@ export async function commandInit(opts) {
|
|
|
1229
865
|
source_path: path.resolve(source),
|
|
1230
866
|
script_path: scriptPath,
|
|
1231
867
|
validation_path: path.join(dd, "validation.json"),
|
|
1232
|
-
episode_total:
|
|
868
|
+
episode_total: totalEpisodes,
|
|
1233
869
|
episode_completed: results.length,
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
batch_reused: skippedEpisodeBatchCount + skippedBatches.length,
|
|
1240
|
-
batch_failed: 0,
|
|
1241
|
-
failed_batches: [],
|
|
1242
|
-
failure_signature: [],
|
|
1243
|
-
failure_streak: 0,
|
|
870
|
+
incomplete_episodes: [],
|
|
871
|
+
batch_total: totalBatches,
|
|
872
|
+
batch_completed: doneBatches,
|
|
873
|
+
batch_reused: reusedBatchCount,
|
|
874
|
+
batch_failed: erroredBatches,
|
|
1244
875
|
last_error: null,
|
|
1245
876
|
review_status: "pending",
|
|
1246
877
|
review_missing: [...REVIEW_TARGETS],
|
|
1247
878
|
inspected_targets: [],
|
|
1248
879
|
patch_count: 0,
|
|
1249
|
-
exportable: providerName !== "mock",
|
|
1250
880
|
});
|
|
1251
881
|
const title = passed
|
|
1252
882
|
? "INIT COMPLETE: Initial script ready"
|
|
@@ -1260,9 +890,8 @@ export async function commandInit(opts) {
|
|
|
1260
890
|
`actions: ${stats["actions"] ?? 0}`,
|
|
1261
891
|
`validation: ${passed ? "passed" : "needs repair"}`,
|
|
1262
892
|
`provider: ${providerName}`,
|
|
1263
|
-
`
|
|
1264
|
-
`batches: ${
|
|
1265
|
-
`batch checkpoint reused: ${skippedEpisodeBatchCount + skippedBatches.length}`,
|
|
893
|
+
`episodes: ${results.length}/${totalEpisodes} complete`,
|
|
894
|
+
`batches: ${doneBatches}/${totalBatches} done (reused ${reusedBatchCount})`,
|
|
1266
895
|
"agent_review: pending",
|
|
1267
896
|
],
|
|
1268
897
|
artifacts: [
|
|
@@ -1288,277 +917,102 @@ export async function commandInit(opts) {
|
|
|
1288
917
|
};
|
|
1289
918
|
return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
|
|
1290
919
|
}
|
|
1291
|
-
export function summarizeIssues(issues) {
|
|
1292
|
-
if (issues.length === 0)
|
|
1293
|
-
return [];
|
|
1294
|
-
const counts = {};
|
|
1295
|
-
for (const item of issues) {
|
|
1296
|
-
const sev = strOf(item["severity"]);
|
|
1297
|
-
counts[sev] = (counts[sev] ?? 0) + 1;
|
|
1298
|
-
}
|
|
1299
|
-
const parts = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)).map(([sev, c]) => `${sev}: ${c}`);
|
|
1300
|
-
const first = issues[0];
|
|
1301
|
-
return [parts.join("; "), `first: ${first["code"]} - ${first["summary"]}`];
|
|
1302
|
-
}
|
|
1303
920
|
// ---------------------------------------------------------------------------
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1307
|
-
//
|
|
1308
|
-
// all), and each asset KIND is registered in its own file —
|
|
1309
|
-
// 人物.md / 场景.md / 道具.md / 发声源.md (+ optional 梗概.md for the whole-script
|
|
1310
|
-
// synopsis). It assembles the same script.initial.json and hands off to the
|
|
1311
|
-
// existing direct inspect/validate/export downstream (zero changes there).
|
|
921
|
+
// command_status — the agent-facing state table, derived entirely from disk: a
|
|
922
|
+
// batch with `<key>.json` is done, with `<key>.error.json` it failed, with
|
|
923
|
+
// neither it is pending. run_state is just a denormalized cache of this, so the
|
|
924
|
+
// agent can read state, then pick exactly which episodes/batches to (re)run.
|
|
1312
925
|
// ---------------------------------------------------------------------------
|
|
1313
|
-
|
|
1314
|
-
const ASSET_DOC_SPECS = [
|
|
1315
|
-
{ kind: "actors", names: ["人物.md", "角色.md", "characters.md", "actors.md"] },
|
|
1316
|
-
{ kind: "locations", names: ["场景.md", "地点.md", "locations.md"] },
|
|
1317
|
-
{ kind: "props", names: ["道具.md", "props.md"] },
|
|
1318
|
-
{ kind: "speakers", names: ["发声源.md", "speakers.md"] },
|
|
1319
|
-
];
|
|
1320
|
-
const SYNOPSIS_DOC_NAMES = ["梗概.md", "全文梗概.md", "synopsis.md"];
|
|
1321
|
-
const META_DOC_NAMES = ["元信息.md", "meta.md"];
|
|
1322
|
-
// Parse whole-script metadata from 元信息.md. Lines like `worldview: 现代` /
|
|
1323
|
-
// `世界观:现代` / `- style: 都市甜宠` / `主角: 陈墨, 苏晴`.
|
|
1324
|
-
function parseMetaDoc(text) {
|
|
1325
|
-
const out = {};
|
|
1326
|
-
for (const line of text.split(/\r?\n/)) {
|
|
1327
|
-
const m = /^\s*[-*]?\s*(title|worldview|style|protagonists|标题|世界观|风格|主角|主角列表)\s*[::]\s*(.+?)\s*$/i.exec(line);
|
|
1328
|
-
if (!m)
|
|
1329
|
-
continue;
|
|
1330
|
-
const key = m[1].toLowerCase();
|
|
1331
|
-
const val = m[2].trim();
|
|
1332
|
-
if (!val)
|
|
1333
|
-
continue;
|
|
1334
|
-
if (key === "title" || key === "标题")
|
|
1335
|
-
out.title = val;
|
|
1336
|
-
else if (key === "worldview" || key === "世界观")
|
|
1337
|
-
out.worldview = val;
|
|
1338
|
-
else if (key === "style" || key === "风格")
|
|
1339
|
-
out.style = val;
|
|
1340
|
-
else if (key === "protagonists" || key === "主角" || key === "主角列表") {
|
|
1341
|
-
out.protagonists = val.split(/[,,、]/).map((s) => s.trim()).filter(Boolean);
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
return out;
|
|
1345
|
-
}
|
|
1346
|
-
function firstExisting(dir, names) {
|
|
1347
|
-
for (const n of names) {
|
|
1348
|
-
const p = path.join(dir, n);
|
|
1349
|
-
if (exists(p) && fs.statSync(p).isFile())
|
|
1350
|
-
return p;
|
|
1351
|
-
}
|
|
1352
|
-
return null;
|
|
1353
|
-
}
|
|
1354
|
-
function collectEpisodeMdFiles(dir) {
|
|
1355
|
-
if (!exists(dir) || !fs.statSync(dir).isDirectory())
|
|
1356
|
-
return [];
|
|
1357
|
-
const out = [];
|
|
1358
|
-
for (const name of fs.readdirSync(dir)) {
|
|
1359
|
-
const m = _EP_FILE_RE.exec(name);
|
|
1360
|
-
if (!m)
|
|
1361
|
-
continue;
|
|
1362
|
-
const full = path.join(dir, name);
|
|
1363
|
-
if (!fs.statSync(full).isFile())
|
|
1364
|
-
continue;
|
|
1365
|
-
out.push({ path: full, episode: parseInt(m[1], 10) });
|
|
1366
|
-
}
|
|
1367
|
-
out.sort((a, b) => a.episode - b.episode);
|
|
1368
|
-
return out;
|
|
1369
|
-
}
|
|
1370
|
-
export async function commandParse(opts) {
|
|
1371
|
-
if (opts["spec"]) {
|
|
1372
|
-
return [{ title: "PARSE SPEC: md 工作区写法", body: PARSE_MD_SPEC }, EXIT_OK];
|
|
1373
|
-
}
|
|
926
|
+
export function commandStatus(opts) {
|
|
1374
927
|
const workspace = strOf(opts["workspace_path"] || "workspace");
|
|
1375
|
-
const
|
|
1376
|
-
const
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
required: ["a directory with per-episode 正文 md + 人物/场景/道具/发声源 md"],
|
|
1381
|
-
received: [mdDir],
|
|
1382
|
-
nextSteps: ["Pass the md workspace dir: scriptctl parse <dir>. Run `scriptctl parse --spec` for the format."],
|
|
1383
|
-
});
|
|
1384
|
-
}
|
|
1385
|
-
let episodesDir = strOf(opts["episodes_dir"]).trim();
|
|
1386
|
-
if (!episodesDir) {
|
|
1387
|
-
const sub = path.join(mdDir, "episodes");
|
|
1388
|
-
episodesDir = exists(sub) && fs.statSync(sub).isDirectory() ? sub : mdDir;
|
|
1389
|
-
}
|
|
1390
|
-
const bodyFiles = collectEpisodeMdFiles(episodesDir);
|
|
1391
|
-
if (bodyFiles.length === 0) {
|
|
1392
|
-
throw new CliError("PARSE BLOCKED: no episode md found", "no episode md found.", {
|
|
928
|
+
const dd = directDir(workspace);
|
|
929
|
+
const episodePlanPath = path.join(dd, "episode_plan.json");
|
|
930
|
+
const batchPlanPath = path.join(dd, "batch_plan.json");
|
|
931
|
+
if (!exists(episodePlanPath) || !exists(batchPlanPath)) {
|
|
932
|
+
throw new CliError("STATUS BLOCKED: Plan not found", "Plan not found.", {
|
|
1393
933
|
exitCode: EXIT_INPUT,
|
|
1394
|
-
required: ["
|
|
1395
|
-
received: [
|
|
1396
|
-
nextSteps: ["
|
|
934
|
+
required: ["episode_plan.json and batch_plan.json"],
|
|
935
|
+
received: [exists(episodePlanPath) ? "episode_plan.json ok" : "episode_plan.json missing"],
|
|
936
|
+
nextSteps: ["Run scriptctl direct init first."],
|
|
1397
937
|
});
|
|
1398
938
|
}
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
const
|
|
1402
|
-
const
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
939
|
+
const episodes = asList(readJson(episodePlanPath)["episodes"]);
|
|
940
|
+
const batches = asList(readJson(batchPlanPath)["batches"]);
|
|
941
|
+
const batchResultsDir = path.join(dd, "batch_results");
|
|
942
|
+
const byEp = new Map();
|
|
943
|
+
let doneTotal = 0;
|
|
944
|
+
let errorTotal = 0;
|
|
945
|
+
for (const batch of batches) {
|
|
946
|
+
const epNum = Number(batch["episode"]);
|
|
947
|
+
if (!byEp.has(epNum))
|
|
948
|
+
byEp.set(epNum, { total: 0, done: 0, error: 0, errorKeys: [], reasons: new Set() });
|
|
949
|
+
const st = byEp.get(epNum);
|
|
950
|
+
st.total++;
|
|
951
|
+
if (exists(batchResultPath(batchResultsDir, batch))) {
|
|
952
|
+
st.done++;
|
|
953
|
+
doneTotal++;
|
|
1406
954
|
continue;
|
|
1407
|
-
assetDocsFound.push(path.basename(p));
|
|
1408
|
-
const parsed = parseAssetDoc(readText(p), spec.kind);
|
|
1409
|
-
for (const key of ["actors", "locations", "props", "speakers", "state_definitions"]) {
|
|
1410
|
-
bible[key].push(...asList(parsed[key]));
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
// Optional whole-script synopsis (梗概.md); strip a leading `# 梗概` header line.
|
|
1414
|
-
let globalSynopsis = "";
|
|
1415
|
-
const synPath = firstExisting(mdDir, SYNOPSIS_DOC_NAMES);
|
|
1416
|
-
if (synPath)
|
|
1417
|
-
globalSynopsis = readText(synPath).replace(/^\s*#\s+\S[^\n]*\n/, "").trim();
|
|
1418
|
-
// Optional whole-script metadata (元信息.md): worldview / style / title.
|
|
1419
|
-
const metaPath = firstExisting(mdDir, META_DOC_NAMES);
|
|
1420
|
-
const meta = metaPath ? parseMetaDoc(readText(metaPath)) : {};
|
|
1421
|
-
const results = [];
|
|
1422
|
-
const sourceChunks = [];
|
|
1423
|
-
for (const file of bodyFiles) {
|
|
1424
|
-
const bodyText = readText(file.path);
|
|
1425
|
-
sourceChunks.push(`# ep_${pad3(file.episode)}\n${bodyText.trim()}`);
|
|
1426
|
-
try {
|
|
1427
|
-
results.push(parseMarkdownBatch(bodyText, { episode: file.episode, part: 1 }, { fragmentMode: true }));
|
|
1428
|
-
}
|
|
1429
|
-
catch (exc) {
|
|
1430
|
-
const e = exc;
|
|
1431
|
-
throw new CliError("PARSE BLOCKED: episode md invalid", "episode md invalid.", {
|
|
1432
|
-
exitCode: EXIT_INPUT,
|
|
1433
|
-
required: ["per-episode 正文 md following `scriptctl parse --spec`"],
|
|
1434
|
-
received: [`${path.basename(file.path)}: ${(e?.message ?? "").slice(0, 200)}`],
|
|
1435
|
-
nextSteps: ["Fix the episode md and re-run parse."],
|
|
1436
|
-
});
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
|
|
1440
|
-
// Fold the registered assets into the first episode result so their
|
|
1441
|
-
// descriptions / states flow into the merge. Names are deduplicated globally
|
|
1442
|
-
// by mergeEpisodeResults, so registering them first gives the canonical
|
|
1443
|
-
// (registry) descriptions priority over anything implied by scene references.
|
|
1444
|
-
if (results.length > 0) {
|
|
1445
|
-
const first = results[0];
|
|
1446
|
-
for (const key of ["actors", "locations", "props", "speakers", "state_definitions"]) {
|
|
1447
|
-
first[key] = [...asList(bible[key]), ...asList(first[key])];
|
|
1448
955
|
}
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
if (meta.protagonists && meta.protagonists.length > 0) {
|
|
1462
|
-
const leads = new Set(meta.protagonists);
|
|
1463
|
-
for (const actor of asList(script["actors"])) {
|
|
1464
|
-
actor["role_type"] = leads.has(strOf(actor["actor_name"])) ? "主角" : "配角";
|
|
956
|
+
const errPath = batchErrorPath(batchResultsDir, batch);
|
|
957
|
+
if (exists(errPath)) {
|
|
958
|
+
st.error++;
|
|
959
|
+
errorTotal++;
|
|
960
|
+
st.errorKeys.push(batchResultKey(batch));
|
|
961
|
+
try {
|
|
962
|
+
const err = readJson(errPath);
|
|
963
|
+
st.reasons.add(strOf(err["error_code"] || err["error_type"] || "Error"));
|
|
964
|
+
}
|
|
965
|
+
catch {
|
|
966
|
+
st.reasons.add("Error");
|
|
967
|
+
}
|
|
1465
968
|
}
|
|
1466
969
|
}
|
|
1467
|
-
const
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
init_stage: "complete",
|
|
1482
|
-
provider: "parse",
|
|
1483
|
-
source_path: path.resolve(mdDir),
|
|
1484
|
-
script_path: scriptPath,
|
|
1485
|
-
validation_path: path.join(dd, "validation.json"),
|
|
1486
|
-
episode_total: results.length,
|
|
1487
|
-
episode_completed: results.length,
|
|
1488
|
-
review_status: "pending",
|
|
1489
|
-
review_missing: [...REVIEW_TARGETS],
|
|
1490
|
-
inspected_targets: [],
|
|
1491
|
-
patch_count: 0,
|
|
1492
|
-
exportable: true,
|
|
1493
|
-
last_error: null,
|
|
1494
|
-
});
|
|
1495
|
-
const stats = validation["stats"] ?? {};
|
|
1496
|
-
const blockingOrError = Boolean(validation["has_blocking"]) ||
|
|
1497
|
-
asList(validation["issues"]).some((it) => isDict(it) && (it["severity"] === "blocking" || it["severity"] === "error"));
|
|
1498
|
-
// --publish: md → 校验 → 直接入库(不经 direct 的 inspect/review/export 门禁)。
|
|
1499
|
-
// md 是唯一真相源:校验不过就报问题、不发布,让 agent 直接改 md 后重跑 parse --publish。
|
|
1500
|
-
if (opts["publish"]) {
|
|
1501
|
-
if (blockingOrError) {
|
|
1502
|
-
return [{
|
|
1503
|
-
title: "PARSE PUBLISH BLOCKED: 校验未过,改 md 后重跑",
|
|
1504
|
-
result: [
|
|
1505
|
-
`asset docs: ${assetDocsFound.join(" / ") || "(none)"}`,
|
|
1506
|
-
`validation: needs repair`,
|
|
1507
|
-
],
|
|
1508
|
-
artifacts: [scriptPath, path.join(dd, "validation.json")],
|
|
1509
|
-
issues: summarizeIssues(asList(validation["issues"])),
|
|
1510
|
-
next: ["按 issue 直接改对应的 ep_*.md / 人物·场景·道具·发声源.md / 元信息.md,再重跑 `scriptctl parse <dir> --publish`。"],
|
|
1511
|
-
}, EXIT_NEEDS_AGENT];
|
|
1512
|
-
}
|
|
1513
|
-
const client = scriptOutputClient(opts);
|
|
1514
|
-
const baseRevision = await currentRevisionOrZero(client);
|
|
1515
|
-
const scriptHash = sha256Text(JSON.stringify(sortDeep(script)));
|
|
1516
|
-
const requestId = strOf(opts["request_id"]).trim() || `scriptctl-parse:${scriptHash}`;
|
|
1517
|
-
let replaceRes;
|
|
1518
|
-
try {
|
|
1519
|
-
replaceRes = await client.replaceScript({ requestId, baseRevision, script, source: "ctl" });
|
|
970
|
+
const lines = [];
|
|
971
|
+
let completeEpisodes = 0;
|
|
972
|
+
const errorEpisodes = [];
|
|
973
|
+
for (const ep of episodes) {
|
|
974
|
+
const epNum = Number(ep["episode"]);
|
|
975
|
+
const st = byEp.get(epNum) ?? { total: 0, done: 0, error: 0, errorKeys: [], reasons: new Set() };
|
|
976
|
+
let label;
|
|
977
|
+
if (st.total > 0 && st.done === st.total) {
|
|
978
|
+
label = "done";
|
|
979
|
+
completeEpisodes++;
|
|
980
|
+
}
|
|
981
|
+
else if (st.error > 0) {
|
|
982
|
+
label = `ERROR (${st.done}/${st.total} done, ${st.error} err: ${[...st.reasons].join(",")}) [${st.errorKeys.join(",")}]`;
|
|
983
|
+
errorEpisodes.push(epNum);
|
|
1520
984
|
}
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
throw apiErrorToCli("PARSE PUBLISH BLOCKED: 入库写入失败", exc);
|
|
1524
|
-
throw exc;
|
|
985
|
+
else {
|
|
986
|
+
label = `pending (${st.done}/${st.total} done)`;
|
|
1525
987
|
}
|
|
1526
|
-
|
|
1527
|
-
title: "PARSE PUBLISHED: 剧本已入库",
|
|
1528
|
-
result: [
|
|
1529
|
-
`episodes: ${stats["episodes"] ?? results.length}`,
|
|
1530
|
-
`scenes: ${stats["scenes"] ?? 0}`,
|
|
1531
|
-
`actions: ${stats["actions"] ?? 0}`,
|
|
1532
|
-
`worldview: ${meta.worldview || "(unset)"}`,
|
|
1533
|
-
`base_revision: ${baseRevision}`,
|
|
1534
|
-
`revision: ${replaceRes["revision"]}`,
|
|
1535
|
-
`idempotent: ${replaceRes["idempotent"] ?? false}`,
|
|
1536
|
-
],
|
|
1537
|
-
artifacts: [scriptPath],
|
|
1538
|
-
next: ["剧本已入库。若发现问题,直接改 md 再 `scriptctl parse <dir> --publish` 覆盖。"],
|
|
1539
|
-
}, EXIT_OK];
|
|
988
|
+
lines.push(`${episodeResultKey(ep)} ${label}`);
|
|
1540
989
|
}
|
|
1541
990
|
const report = {
|
|
1542
|
-
title:
|
|
1543
|
-
? "PARSE COMPLETE: 中间稿已生成(加 --publish 直接入库)"
|
|
1544
|
-
: "PARSE NEEDS AGENT: 校验有问题,直接改 md 再 parse",
|
|
991
|
+
title: "DIRECT STATUS",
|
|
1545
992
|
result: [
|
|
1546
|
-
`episodes: ${
|
|
1547
|
-
`
|
|
1548
|
-
`
|
|
1549
|
-
|
|
1550
|
-
`worldview: ${meta.worldview || "(unset)"}`,
|
|
1551
|
-
`style: ${meta.style || "(unset)"}`,
|
|
1552
|
-
`synopsis: ${globalSynopsis ? "yes" : "no"}`,
|
|
1553
|
-
`validation: ${passed ? "passed" : "needs repair"}`,
|
|
993
|
+
`episodes: ${completeEpisodes}/${episodes.length} done`,
|
|
994
|
+
`batches: ${doneTotal}/${batches.length} done, ${errorTotal} error`,
|
|
995
|
+
`error episodes: ${errorEpisodes.length === 0 ? "-" : errorEpisodes.join(", ")}`,
|
|
996
|
+
...lines,
|
|
1554
997
|
],
|
|
1555
|
-
artifacts: [
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
: ["按 issue 直接改对应的 md(ep_*.md / 资产 md / 元信息.md),再重跑 parse。"],
|
|
998
|
+
artifacts: [batchResultsDir, path.join(dd, "episode_results"), path.join(dd, "run_state.json")],
|
|
999
|
+
next: errorEpisodes.length > 0
|
|
1000
|
+
? [`Re-run an errored episode with: direct init --episodes ${errorEpisodes[0]} (or --batches <key>, or --retry-errors).`]
|
|
1001
|
+
: ["All batches accounted for."],
|
|
1560
1002
|
};
|
|
1561
|
-
return [report,
|
|
1003
|
+
return [report, EXIT_OK];
|
|
1004
|
+
}
|
|
1005
|
+
export function summarizeIssues(issues) {
|
|
1006
|
+
if (issues.length === 0)
|
|
1007
|
+
return [];
|
|
1008
|
+
const counts = {};
|
|
1009
|
+
for (const item of issues) {
|
|
1010
|
+
const sev = strOf(item["severity"]);
|
|
1011
|
+
counts[sev] = (counts[sev] ?? 0) + 1;
|
|
1012
|
+
}
|
|
1013
|
+
const parts = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)).map(([sev, c]) => `${sev}: ${c}`);
|
|
1014
|
+
const first = issues[0];
|
|
1015
|
+
return [parts.join("; "), `first: ${first["code"]} - ${first["summary"]}`];
|
|
1562
1016
|
}
|
|
1563
1017
|
// ---------------------------------------------------------------------------
|
|
1564
1018
|
// command_validate
|