@lingjingai/scriptctl 0.27.2 → 0.28.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.
@@ -171,6 +171,29 @@ function readBatchResult(dir, batch) {
171
171
  return null;
172
172
  }
173
173
  }
174
+ function isDirectStageName(value) {
175
+ switch (value) {
176
+ case "source_prepare":
177
+ case "episode_plan":
178
+ case "batch_plan":
179
+ case "episode_titles":
180
+ case "batch_extract":
181
+ case "episode_merge":
182
+ case "episode_synopsis":
183
+ case "script_merge":
184
+ case "asset_curation":
185
+ case "metadata_extract":
186
+ case "script_synopsis":
187
+ case "validate":
188
+ return true;
189
+ default:
190
+ return false;
191
+ }
192
+ }
193
+ let progressFrame = 0;
194
+ let activeProgressFrame = null;
195
+ let progressTimer = null;
196
+ let progressBlockLines = 0;
174
197
  // Single source of truth for a batch's on-disk state, shared by init (what to
175
198
  // (re)run) and status (what to report) so the two can never disagree. "done"
176
199
  // requires a result that actually PARSES — a present-but-corrupt result is
@@ -182,6 +205,579 @@ function classifyBatch(dir, batch) {
182
205
  return "error";
183
206
  return "pending";
184
207
  }
208
+ function statusFromBatchState(state) {
209
+ return state === "done" ? "success" : state;
210
+ }
211
+ function stageSnapshot(name, status, completed, total, message) {
212
+ const boundedTotal = Math.max(0, total);
213
+ const boundedCompleted = Math.max(0, Math.min(completed, boundedTotal));
214
+ const percent = boundedTotal > 0 ? Math.round((boundedCompleted / boundedTotal) * 100) : status === "success" ? 100 : 0;
215
+ return {
216
+ name,
217
+ status,
218
+ completed: boundedCompleted,
219
+ total: boundedTotal,
220
+ percent,
221
+ pending: status === "skipped" ? 0 : Math.max(0, boundedTotal - boundedCompleted),
222
+ warning: status === "warning" ? 1 : 0,
223
+ error: status === "error" ? 1 : 0,
224
+ message: message ?? "",
225
+ };
226
+ }
227
+ function fileStage(name, filePath, runningStage) {
228
+ if (exists(filePath))
229
+ return stageSnapshot(name, "success", 1, 1);
230
+ return stageSnapshot(name, runningStage === name ? "running" : "pending", 0, 1);
231
+ }
232
+ function readJsonFile(filePath, fallback) {
233
+ if (!exists(filePath))
234
+ return fallback;
235
+ return readJson(filePath);
236
+ }
237
+ function readableJsonFile(filePath) {
238
+ if (!exists(filePath))
239
+ return false;
240
+ try {
241
+ readJson(filePath);
242
+ return true;
243
+ }
244
+ catch {
245
+ return false;
246
+ }
247
+ }
248
+ function stageUnit(name) {
249
+ if (name === "batch_extract")
250
+ return "progress";
251
+ if (name === "episode_merge" || name === "episode_synopsis" || name === "episode_titles")
252
+ return "items";
253
+ return "step";
254
+ }
255
+ function stageLabel(name) {
256
+ switch (name) {
257
+ case "source_prepare": return "准备素材";
258
+ case "episode_plan": return "阅读剧本";
259
+ case "batch_plan": return "理解剧情";
260
+ case "episode_titles": return "整理内容";
261
+ case "batch_extract": return "整理内容";
262
+ case "episode_merge": return "生成剧本";
263
+ case "episode_synopsis": return "补充信息";
264
+ case "script_merge": return "生成剧本";
265
+ case "asset_curation": return "补充信息";
266
+ case "metadata_extract": return "补充信息";
267
+ case "script_synopsis": return "补充信息";
268
+ case "validate": return "检查结果";
269
+ }
270
+ }
271
+ function statusText(status) {
272
+ switch (status) {
273
+ case "success": return "完成";
274
+ case "running": return "进行中";
275
+ case "pending": return "等待中";
276
+ case "warning": return "需留意";
277
+ case "error": return "出错";
278
+ case "skipped": return "已跳过";
279
+ }
280
+ }
281
+ function statusPriority(status) {
282
+ switch (status) {
283
+ case "error": return 5;
284
+ case "warning": return 4;
285
+ case "running": return 3;
286
+ case "pending": return 2;
287
+ case "skipped": return 1;
288
+ default: return 0;
289
+ }
290
+ }
291
+ function userStageRows(stages) {
292
+ const byLabel = new Map();
293
+ for (const stage of stages) {
294
+ const label = stageLabel(stage.name);
295
+ byLabel.set(label, [...(byLabel.get(label) ?? []), stage]);
296
+ }
297
+ const rows = [];
298
+ for (const items of byLabel.values()) {
299
+ const total = items.reduce((sum, item) => sum + stageWeight(item.name), 0);
300
+ const completed = items.reduce((sum, item) => sum + stageWeight(item.name) * (item.status === "skipped" ? 1 : item.percent / 100), 0);
301
+ const status = items.reduce((max, item) => statusPriority(item.status) > statusPriority(max) ? item.status : max, "success");
302
+ rows.push({
303
+ name: items[0].name,
304
+ status,
305
+ completed: Math.round(completed),
306
+ total,
307
+ percent: total > 0 ? Math.round((completed / total) * 100) : 0,
308
+ pending: items.reduce((sum, item) => sum + item.pending, 0),
309
+ warning: items.reduce((sum, item) => sum + item.warning, 0),
310
+ error: items.reduce((sum, item) => sum + item.error, 0),
311
+ message: items.map((item) => item.message).filter(Boolean).join("; "),
312
+ });
313
+ }
314
+ return rows;
315
+ }
316
+ function stageWeight(name) {
317
+ switch (name) {
318
+ case "source_prepare": return 3;
319
+ case "episode_plan": return 7;
320
+ case "batch_plan": return 5;
321
+ case "episode_titles": return 5;
322
+ case "batch_extract": return 45;
323
+ case "episode_merge": return 8;
324
+ case "episode_synopsis": return 5;
325
+ case "script_merge": return 7;
326
+ case "asset_curation": return 5;
327
+ case "metadata_extract": return 3;
328
+ case "script_synopsis": return 5;
329
+ case "validate": return 2;
330
+ }
331
+ }
332
+ function snapshotProgressPercent(stages) {
333
+ let done = 0;
334
+ let total = 0;
335
+ for (const stage of stages) {
336
+ const weight = stageWeight(stage.name);
337
+ total += weight;
338
+ done += weight * (stage.status === "skipped" ? 1 : stage.percent / 100);
339
+ }
340
+ return total > 0 ? Math.max(0, Math.min(100, Math.round((done / total) * 100))) : 0;
341
+ }
342
+ function useProgressColor() {
343
+ return Boolean(process.stderr.isTTY) && process.env.NO_COLOR === undefined;
344
+ }
345
+ function ansi(code, text) {
346
+ return useProgressColor() ? `\x1b[${code}m${text}\x1b[0m` : text;
347
+ }
348
+ function colorStatus(status, text) {
349
+ switch (status) {
350
+ case "success": return ansi(32, text);
351
+ case "error": return ansi(31, text);
352
+ case "warning": return ansi(33, text);
353
+ case "running": return ansi(36, text);
354
+ case "pending": return ansi(34, text);
355
+ case "skipped": return ansi(90, text);
356
+ }
357
+ }
358
+ function dim(text) {
359
+ return ansi(2, text);
360
+ }
361
+ function bold(text) {
362
+ return ansi(1, text);
363
+ }
364
+ function progressBar(stage) {
365
+ const width = 24;
366
+ const ratio = stage.percent / 100;
367
+ const filled = Math.round(ratio * width);
368
+ const bar = `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
369
+ return colorStatus(stage.status, bar);
370
+ }
371
+ function statusMark(stage) {
372
+ if (stage.status === "running") {
373
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
374
+ const frame = frames[progressFrame % frames.length];
375
+ progressFrame += 1;
376
+ return ansi(36, frame);
377
+ }
378
+ if (stage.status === "success")
379
+ return ansi(32, "✓");
380
+ if (stage.status === "error")
381
+ return ansi(31, "✖");
382
+ if (stage.status === "warning")
383
+ return ansi(33, "!");
384
+ if (stage.status === "skipped")
385
+ return ansi(90, "-");
386
+ return ansi(34, "•");
387
+ }
388
+ function renderProgressLine(stage) {
389
+ const status = colorStatus(stage.status, stage.status.padEnd(7));
390
+ const percent = `${stage.percent}%`;
391
+ const bits = [
392
+ statusMark(stage),
393
+ bold(stageLabel(stage.name)),
394
+ status,
395
+ progressBar(stage),
396
+ `${percent} ${stageUnit(stage.name)}`,
397
+ ];
398
+ if (stage.error > 0)
399
+ bits.push(ansi(31, `error ${stage.error}`));
400
+ if (stage.warning > 0)
401
+ bits.push(ansi(33, `warning ${stage.warning}`));
402
+ if (stage.status === "pending")
403
+ bits.push(ansi(34, statusText("pending")));
404
+ if (stage.message)
405
+ bits.push(dim(`- ${stage.message}`));
406
+ return bits.join(" ");
407
+ }
408
+ function renderStageDetail(stage) {
409
+ const counts = [`${stage.percent}%`];
410
+ if (stage.error > 0)
411
+ counts.push(ansi(31, `error ${stage.error}`));
412
+ if (stage.warning > 0)
413
+ counts.push(ansi(33, `warning ${stage.warning}`));
414
+ if (stage.status === "pending")
415
+ counts.push(ansi(34, statusText("pending")));
416
+ return ` ${dim("stage")} ${stageLabel(stage.name)} ${colorStatus(stage.status, statusText(stage.status))} ${counts.join(", ")}`;
417
+ }
418
+ function renderEpisodeDetail(episode) {
419
+ const percent = episode.batches_total > 0 ? Math.round((episode.batches_done / episode.batches_total) * 100) : 0;
420
+ const errors = episode.error_batches.length > 0 ? ansi(31, " has errors") : "";
421
+ return ` ${dim("episode")} ${episode.episode_id} ${colorStatus(episode.status, statusText(episode.status))} ${percent}%, story ${colorStatus(episode.synopsis_status, statusText(episode.synopsis_status))}${errors}`;
422
+ }
423
+ function renderBatchDetail(batch) {
424
+ const reason = batch.error_code || batch.error_type || batch.message;
425
+ return ` ${dim("detail")} ${batch.batch_id} ${colorStatus(batch.status, statusText(batch.status))} ep_${pad3(batch.episode)}${reason ? ` - ${reason}` : ""}`;
426
+ }
427
+ function capDetails(lines, total, label) {
428
+ if (total <= lines.length)
429
+ return lines;
430
+ return [...lines, ` ${dim(`${total - lines.length} more ${label}`)}`];
431
+ }
432
+ function renderProgressDetails(snapshot, stageName) {
433
+ if (stageName === "batch_extract") {
434
+ const episodes = snapshot.episodes.filter((episode) => episode.status !== "success");
435
+ return [
436
+ ...capDetails(episodes.slice(0, 4).map((episode) => renderEpisodeDetail(episode)), episodes.length, "episodes"),
437
+ ];
438
+ }
439
+ if (stageName === "episode_titles" || stageName === "episode_merge" || stageName === "episode_synopsis") {
440
+ const episodes = snapshot.episodes.filter((episode) => episode.status !== "success" || episode.synopsis_status !== "success");
441
+ return capDetails(episodes.slice(0, 6).map((episode) => renderEpisodeDetail(episode)), episodes.length, "episodes");
442
+ }
443
+ const stages = snapshot.stages.filter((stage) => stage.status === "running" || stage.status === "pending" || stage.status === "warning" || stage.status === "error");
444
+ return capDetails(stages.slice(0, 5).map((stage) => renderStageDetail(stage)), stages.length, "stages");
445
+ }
446
+ function renderProgressFrameText(frame) {
447
+ return [renderProgressLine(frame.stage), ...frame.details].join("\n");
448
+ }
449
+ function renderProgressFrame() {
450
+ if (activeProgressFrame)
451
+ updateProgressBlock(renderProgressFrameText(activeProgressFrame));
452
+ }
453
+ function progressStageWithStatus(stage, status) {
454
+ if (status === "success")
455
+ return { ...stage, status, completed: stage.total, percent: 100, pending: 0, warning: 0, error: 0 };
456
+ if (status === "warning")
457
+ return { ...stage, status, completed: stage.total, percent: 100, pending: 0, warning: Math.max(1, stage.warning), error: 0 };
458
+ if (status === "skipped")
459
+ return { ...stage, status, percent: 100, pending: 0, warning: 0, error: 0 };
460
+ if (status === "error")
461
+ return { ...stage, status, error: Math.max(1, stage.error) };
462
+ return { ...stage, status };
463
+ }
464
+ function stopProgressTimer() {
465
+ if (!progressTimer)
466
+ return;
467
+ clearInterval(progressTimer);
468
+ progressTimer = null;
469
+ }
470
+ function ensureProgressTimer() {
471
+ if (!process.stderr.isTTY || progressTimer !== null)
472
+ return;
473
+ progressTimer = setInterval(renderProgressFrame, 80);
474
+ }
475
+ function eraseProgressBlock() {
476
+ if (progressBlockLines === 0)
477
+ return;
478
+ process.stderr.write(`\x1b[${progressBlockLines}A`);
479
+ for (let i = 0; i < progressBlockLines; i++) {
480
+ process.stderr.write("\x1b[2K");
481
+ if (i < progressBlockLines - 1)
482
+ process.stderr.write("\x1b[1B");
483
+ }
484
+ process.stderr.write("\r");
485
+ if (progressBlockLines > 1)
486
+ process.stderr.write(`\x1b[${progressBlockLines - 1}A`);
487
+ progressBlockLines = 0;
488
+ }
489
+ function updateProgressBlock(text) {
490
+ eraseProgressBlock();
491
+ process.stderr.write(text + "\n");
492
+ progressBlockLines = text.split("\n").length;
493
+ }
494
+ function persistProgressLine(text) {
495
+ eraseProgressBlock();
496
+ process.stderr.write(text + "\n");
497
+ }
498
+ function emitProgressStage(stage, details = []) {
499
+ const line = renderProgressLine(stage);
500
+ if (!process.stderr.isTTY) {
501
+ process.stderr.write(line + "\n");
502
+ return;
503
+ }
504
+ activeProgressFrame = { stage, details: stage.status === "running" ? details : [] };
505
+ if (stage.status === "running") {
506
+ ensureProgressTimer();
507
+ updateProgressBlock(renderProgressFrameText(activeProgressFrame));
508
+ return;
509
+ }
510
+ stopProgressTimer();
511
+ persistProgressLine(line);
512
+ activeProgressFrame = null;
513
+ }
514
+ function finishInitProgress() {
515
+ stopProgressTimer();
516
+ if (process.stderr.isTTY)
517
+ eraseProgressBlock();
518
+ }
519
+ function emitInitProgress(workspace, stageName, fallbackStatus = "running") {
520
+ const dd = directDir(workspace);
521
+ const episodePlanPath = path.join(dd, "episode_plan.json");
522
+ const batchPlanPath = path.join(dd, "batch_plan.json");
523
+ if (exists(episodePlanPath) && exists(batchPlanPath)) {
524
+ const snapshot = buildDirectStatusSnapshot(workspace);
525
+ const stage = snapshot.stages.find((item) => item.name === stageName);
526
+ if (stage) {
527
+ const renderedStage = fallbackStatus === "running" ? stage : progressStageWithStatus(stage, fallbackStatus);
528
+ emitProgressStage(renderedStage, renderProgressDetails(snapshot, stageName));
529
+ return;
530
+ }
531
+ }
532
+ emitProgressStage({
533
+ name: stageName,
534
+ status: fallbackStatus,
535
+ completed: fallbackStatus === "success" ? 1 : 0,
536
+ total: 1,
537
+ percent: fallbackStatus === "success" ? 100 : 0,
538
+ pending: fallbackStatus === "running" || fallbackStatus === "pending" ? 1 : 0,
539
+ warning: fallbackStatus === "warning" ? 1 : 0,
540
+ error: fallbackStatus === "error" ? 1 : 0,
541
+ message: "",
542
+ });
543
+ }
544
+ function emitFailedInitProgress(workspace) {
545
+ const stage = strOf(readRunState(workspace)["init_stage"]);
546
+ if (isDirectStageName(stage))
547
+ emitInitProgress(workspace, stage, "error");
548
+ }
549
+ function buildDirectStatusSnapshot(workspace) {
550
+ const dd = directDir(workspace);
551
+ const episodePlanPath = path.join(dd, "episode_plan.json");
552
+ const batchPlanPath = path.join(dd, "batch_plan.json");
553
+ const episodePlan = readJsonFile(episodePlanPath, {});
554
+ const batchPlan = readJsonFile(batchPlanPath, {});
555
+ const episodes = asList(episodePlan["episodes"]);
556
+ const batches = asList(batchPlan["batches"]);
557
+ const state = readRunState(workspace);
558
+ const rawRunningStage = strOf(state["init_stage"]);
559
+ const runningStage = rawRunningStage && rawRunningStage !== "complete" ? rawRunningStage : null;
560
+ const runStatus = strOf(state["status"]);
561
+ const failedStage = runStatus === "init_failed" ? runningStage : null;
562
+ const batchResultsDir = path.join(dd, "batch_results");
563
+ const episodeResultsDir = path.join(dd, "episode_results");
564
+ const batchesByEpisode = new Map();
565
+ const batchDetails = [];
566
+ let doneBatches = 0;
567
+ let errorBatches = 0;
568
+ for (const batch of batches) {
569
+ const batchId = batchResultKey(batch);
570
+ const status = statusFromBatchState(classifyBatch(batchResultsDir, batch));
571
+ if (status === "success")
572
+ doneBatches++;
573
+ if (status === "error")
574
+ errorBatches++;
575
+ const detail = {
576
+ batch_id: batchId,
577
+ episode: Number(batch["episode"]),
578
+ part: Number(batch["part"] ?? 0),
579
+ status,
580
+ };
581
+ if (status === "error") {
582
+ try {
583
+ const err = readJson(batchErrorPath(batchResultsDir, batch));
584
+ const errorCode = strOf(err["error_code"]);
585
+ const errorType = strOf(err["error_type"]);
586
+ const message = strOf(err["message"]);
587
+ if (errorCode)
588
+ detail.error_code = errorCode;
589
+ if (errorType)
590
+ detail.error_type = errorType;
591
+ if (message)
592
+ detail.message = message;
593
+ }
594
+ catch {
595
+ detail.error_type = "Error";
596
+ }
597
+ }
598
+ batchDetails.push(detail);
599
+ if (!batchesByEpisode.has(detail.episode))
600
+ batchesByEpisode.set(detail.episode, []);
601
+ batchesByEpisode.get(detail.episode).push(detail);
602
+ }
603
+ const pendingBatches = batches.length - doneBatches - errorBatches;
604
+ const degradedSynopsisEpisodes = new Set(asList(state["summary_degraded_episodes"]).map((n) => Number(n)));
605
+ const episodeSnapshots = [];
606
+ let completeEpisodes = 0;
607
+ let episodeResults = 0;
608
+ let episodeSynopses = 0;
609
+ for (const episode of episodes) {
610
+ const episodeNum = Number(episode["episode"]);
611
+ const epBatches = batchesByEpisode.get(episodeNum) ?? [];
612
+ const batchesDone = epBatches.filter((b) => b.status === "success").length;
613
+ const batchesError = epBatches.filter((b) => b.status === "error").length;
614
+ const batchesPending = epBatches.length - batchesDone - batchesError;
615
+ const epResultPath = episodeResultPath(episodeResultsDir, episode);
616
+ const hasEpisodeResult = readableJsonFile(epResultPath);
617
+ if (hasEpisodeResult)
618
+ episodeResults++;
619
+ const synopsis = readEpisodeSynopsis(episodeResultsDir, episode);
620
+ if (synopsis)
621
+ episodeSynopses++;
622
+ const status = batchesError > 0
623
+ ? "error"
624
+ : epBatches.length > 0 && batchesDone === epBatches.length && hasEpisodeResult
625
+ ? "success"
626
+ : runningStage === "batch_extract" || runningStage === "episode_merge"
627
+ ? "running"
628
+ : "pending";
629
+ if (status === "success")
630
+ completeEpisodes++;
631
+ const synopsisStatus = degradedSynopsisEpisodes.has(episodeNum)
632
+ ? "warning"
633
+ : synopsis
634
+ ? "success"
635
+ : Boolean(state["summary_skipped"]) && !exists(path.join(dd, "synopsis.json"))
636
+ ? "skipped"
637
+ : "pending";
638
+ episodeSnapshots.push({
639
+ episode: episodeNum,
640
+ episode_id: episodeResultKey(episode),
641
+ status,
642
+ batches_done: batchesDone,
643
+ batches_total: epBatches.length,
644
+ batches_error: batchesError,
645
+ batches_pending: batchesPending,
646
+ synopsis_status: synopsisStatus,
647
+ error_batches: epBatches.filter((b) => b.status === "error").map((b) => b.batch_id),
648
+ });
649
+ }
650
+ const sourceStage = exists(path.join(workspace, "source.txt"))
651
+ ? stageSnapshot("source_prepare", "success", 1, 1)
652
+ : stageSnapshot("source_prepare", runningStage === "source_prepare" ? "running" : "pending", 0, 1);
653
+ const episodePlanStage = episodes.length > 0
654
+ ? stageSnapshot("episode_plan", "success", episodes.length, episodes.length)
655
+ : stageSnapshot("episode_plan", runningStage === "episode_plan" ? "running" : "pending", 0, 1);
656
+ const batchPlanStage = batches.length > 0
657
+ ? stageSnapshot("batch_plan", "success", batches.length, batches.length)
658
+ : stageSnapshot("batch_plan", runningStage === "batch_plan" ? "running" : "pending", 0, 1);
659
+ const titleDeg = isDict(state["title_degraded"]) ? state["title_degraded"] : null;
660
+ const episodeTitlesStage = stageSnapshot("episode_titles", episodes.length === 0
661
+ ? runningStage === "episode_titles" ? "running" : "pending"
662
+ : titleDeg && Number(titleDeg["count"] ?? 0) > 0 ? "warning" : "success", episodes.length > 0 ? episodes.length : 0, episodes.length > 0 ? episodes.length : 1, titleDeg && Number(titleDeg["count"] ?? 0) > 0
663
+ ? `deterministic titles: ${asList(titleDeg["episodes"]).join(", ")}`
664
+ : "");
665
+ const batchStatus = errorBatches > 0
666
+ ? "error"
667
+ : doneBatches === batches.length && batches.length > 0
668
+ ? "success"
669
+ : runningStage === "batch_extract"
670
+ ? "running"
671
+ : "pending";
672
+ const batchStage = {
673
+ ...stageSnapshot("batch_extract", batchStatus, doneBatches, batches.length),
674
+ pending: pendingBatches,
675
+ error: errorBatches,
676
+ };
677
+ const episodeMergeStatus = errorBatches > 0
678
+ ? "error"
679
+ : episodeResults === episodes.length && episodes.length > 0
680
+ ? "success"
681
+ : runningStage === "episode_merge"
682
+ ? "running"
683
+ : "pending";
684
+ const episodeMergeStage = stageSnapshot("episode_merge", episodeMergeStatus, episodeResults, episodes.length);
685
+ const synopsisWarning = degradedSynopsisEpisodes.size;
686
+ const episodeSynopsisStatus = synopsisWarning > 0
687
+ ? "warning"
688
+ : episodeSynopses === episodes.length && episodes.length > 0
689
+ ? "success"
690
+ : runningStage === "episode_synopsis"
691
+ ? "running"
692
+ : "pending";
693
+ const episodeSynopsisStage = {
694
+ ...stageSnapshot("episode_synopsis", episodeSynopsisStatus, episodeSynopses, episodes.length),
695
+ warning: synopsisWarning,
696
+ };
697
+ const scriptStage = fileStage("script_merge", path.join(dd, "script.initial.json"), runningStage ?? "");
698
+ const curationStage = fileStage("asset_curation", path.join(dd, "asset_curation.json"), runningStage ?? "");
699
+ const metadataStage = fileStage("metadata_extract", path.join(dd, "asset_metadata.json"), runningStage ?? "");
700
+ const synopsisPath = path.join(dd, "synopsis.json");
701
+ const summaryLine = formatSummaryStatusLine(Boolean(state["summary_skipped"]), Boolean(state["summary_degraded"]), asList(state["summary_degraded_episodes"]).map((n) => Number(n)));
702
+ const scriptSynopsisStatus = exists(synopsisPath)
703
+ ? Boolean(state["summary_degraded"]) ? "warning" : "success"
704
+ : Boolean(state["summary_skipped"])
705
+ ? "skipped"
706
+ : runningStage === "script_synopsis"
707
+ ? "running"
708
+ : "pending";
709
+ const scriptSynopsisStage = stageSnapshot("script_synopsis", scriptSynopsisStatus, exists(synopsisPath) ? 1 : 0, 1, summaryLine ?? "");
710
+ const validationPath = path.join(dd, "validation.json");
711
+ let validationStage = fileStage("validate", validationPath, runningStage ?? "");
712
+ if (exists(validationPath)) {
713
+ const validation = readJson(validationPath);
714
+ validationStage = stageSnapshot("validate", Boolean(validation["passed"]) ? "success" : "warning", 1, 1);
715
+ }
716
+ const stages = [
717
+ sourceStage,
718
+ episodePlanStage,
719
+ batchPlanStage,
720
+ episodeTitlesStage,
721
+ batchStage,
722
+ episodeMergeStage,
723
+ episodeSynopsisStage,
724
+ scriptStage,
725
+ curationStage,
726
+ metadataStage,
727
+ scriptSynopsisStage,
728
+ validationStage,
729
+ ].map((stage) => failedStage === stage.name ? { ...stage, status: "error", error: Math.max(1, stage.error) } : stage);
730
+ let overallStatus = "pending";
731
+ const maxStage = stages.reduce((max, stage) => statusPriority(stage.status) > statusPriority(max) ? stage.status : max, "success");
732
+ if (runStatus === "init_failed" || runStatus === "init_incomplete" || maxStage === "error")
733
+ overallStatus = "error";
734
+ else if (runStatus === "init_running" || maxStage === "running")
735
+ overallStatus = "running";
736
+ else if (maxStage === "warning")
737
+ overallStatus = "warning";
738
+ else if (stages.filter((s) => s.name !== "script_synopsis").every((s) => s.status === "success" || s.status === "warning"))
739
+ overallStatus = "success";
740
+ return {
741
+ overall_status: overallStatus,
742
+ running_stage: runningStage,
743
+ progress_percent: snapshotProgressPercent(stages),
744
+ stages,
745
+ episodes: episodeSnapshots,
746
+ batches: batchDetails,
747
+ };
748
+ }
749
+ function renderDirectStatusResult(snapshot) {
750
+ const completedEpisodes = snapshot.episodes.filter((episode) => episode.status === "success").length;
751
+ const result = [
752
+ "Overall",
753
+ `overall: ${statusText(snapshot.overall_status)}; current: ${snapshot.running_stage && isDirectStageName(snapshot.running_stage) ? stageLabel(snapshot.running_stage) : "-"}; progress: ${snapshot.progress_percent}%`,
754
+ snapshot.episodes.length > 0 ? `episodes: ${Math.round((completedEpisodes / snapshot.episodes.length) * 100)}% 完成` : "episodes: 等待中",
755
+ "Stages",
756
+ ];
757
+ for (const stage of userStageRows(snapshot.stages)) {
758
+ result.push(`${stageLabel(stage.name)}: ${statusText(stage.status)} ${stage.percent}%${stage.error ? `, error ${stage.error}` : ""}${stage.warning ? `, warning ${stage.warning}` : ""}${stage.status === "pending" ? ", 等待中" : ""}${stage.message ? ` - ${stage.message}` : ""}`);
759
+ }
760
+ result.push("Episodes");
761
+ for (const episode of snapshot.episodes) {
762
+ const percent = episode.batches_total > 0 ? Math.round((episode.batches_done / episode.batches_total) * 100) : 0;
763
+ const errors = episode.error_batches.length > 0 ? `, error ids: ${episode.error_batches.join(",")}` : "";
764
+ result.push(`${episode.episode_id}: ${statusText(episode.status)} ${percent}%, story ${statusText(episode.synopsis_status)}${errors}`);
765
+ }
766
+ result.push("Details");
767
+ const noisyBatches = snapshot.batches.filter((batch) => batch.status !== "success");
768
+ if (noisyBatches.length === 0) {
769
+ result.push("no error/warning/pending details");
770
+ }
771
+ else {
772
+ for (const batch of noisyBatches.slice(0, 50)) {
773
+ const reason = batch.error_code || batch.error_type || batch.message;
774
+ result.push(`${batch.batch_id}: ${statusText(batch.status)} ep_${pad3(batch.episode)}${reason ? ` - ${reason}` : ""}`);
775
+ }
776
+ if (noisyBatches.length > 50)
777
+ result.push(`${noisyBatches.length - 50} more non-success details not shown`);
778
+ }
779
+ return result;
780
+ }
185
781
  // Delete result/meta/error/markdown files whose unit key is no longer in the
186
782
  // current plan (e.g. the source shed an episode). Pure function of the plan —
187
783
  // it never inspects hashes, content, or run_state, so it can only remove units
@@ -659,6 +1255,19 @@ async function buildScriptSynopsis(provider, script, opts) {
659
1255
  // command_init
660
1256
  // ---------------------------------------------------------------------------
661
1257
  export async function commandInit(opts) {
1258
+ const workspace = strOf(opts["workspace_path"] || "workspace");
1259
+ try {
1260
+ return await commandInitImpl(opts);
1261
+ }
1262
+ catch (exc) {
1263
+ emitFailedInitProgress(workspace);
1264
+ throw exc;
1265
+ }
1266
+ finally {
1267
+ finishInitProgress();
1268
+ }
1269
+ }
1270
+ async function commandInitImpl(opts) {
662
1271
  const sourcePathArg = strOf(opts["source_path"]);
663
1272
  const source = sourcePathArg.startsWith("~")
664
1273
  ? path.join(process.env.HOME ?? "", sourcePathArg.slice(1))
@@ -731,6 +1340,7 @@ export async function commandInit(opts) {
731
1340
  concurrency,
732
1341
  source_path: path.resolve(source),
733
1342
  });
1343
+ emitInitProgress(workspace, "source_prepare");
734
1344
  let info;
735
1345
  try {
736
1346
  info = await prepareSource(source, workspace);
@@ -762,6 +1372,7 @@ export async function commandInit(opts) {
762
1372
  const sourceText = readText(sourceTextPath);
763
1373
  const manifest = makeSourceManifest(source, sourceTextPath, info);
764
1374
  updateRunState(workspace, { status: "init_running", init_stage: "episode_plan" });
1375
+ emitInitProgress(workspace, "episode_plan");
765
1376
  let plan;
766
1377
  try {
767
1378
  plan = buildEpisodePlan(sourceText);
@@ -776,12 +1387,12 @@ export async function commandInit(opts) {
776
1387
  nextSteps: ["Inspect workspace/source.txt, fix the source file, and rerun init."],
777
1388
  });
778
1389
  }
779
- updateRunState(workspace, { status: "init_running", init_stage: "provider" });
1390
+ updateRunState(workspace, { status: "init_running", init_stage: "episode_titles" });
780
1391
  const capabilities = providerCapabilities(providerName);
781
1392
  if (capabilities && !capabilities.extractBatch && !capabilities.extractEpisode) {
782
1393
  throw initFailedReport(workspace, {
783
1394
  title: "INIT FAILED: provider lacks batch extraction",
784
- stage: "provider",
1395
+ stage: "episode_titles",
785
1396
  required: ["provider.extractBatch or provider.extractEpisode"],
786
1397
  received: [`--provider ${providerName}`],
787
1398
  nextSteps: ["Use --provider anthropic or --provider mock for direct init."],
@@ -795,7 +1406,7 @@ export async function commandInit(opts) {
795
1406
  if (exc instanceof CliError) {
796
1407
  updateRunState(workspace, {
797
1408
  status: "init_failed",
798
- init_stage: "provider",
1409
+ init_stage: "episode_titles",
799
1410
  last_error: { title: exc.title, received: exc.received, failed_at: checkpointTimestamp() },
800
1411
  });
801
1412
  }
@@ -804,13 +1415,14 @@ export async function commandInit(opts) {
804
1415
  if (!provider.extractBatch && !provider.extractEpisode) {
805
1416
  throw initFailedReport(workspace, {
806
1417
  title: "INIT FAILED: provider lacks batch extraction",
807
- stage: "provider",
1418
+ stage: "episode_titles",
808
1419
  required: ["provider.extractBatch or provider.extractEpisode"],
809
1420
  received: [`--provider ${providerName}`],
810
1421
  nextSteps: ["Use --provider anthropic or --provider mock for direct init."],
811
1422
  });
812
1423
  }
813
1424
  updateRunState(workspace, { status: "init_running", init_stage: "episode_titles" });
1425
+ emitInitProgress(workspace, "episode_titles");
814
1426
  const titleDegraded = await enrichEpisodeTitlesChunked(workspace, sourceText, plan, provider, concurrency);
815
1427
  let batchPlan;
816
1428
  try {
@@ -834,6 +1446,7 @@ export async function commandInit(opts) {
834
1446
  writeJson(path.join(dd, "source_manifest.json"), manifest);
835
1447
  writeJson(path.join(dd, "episode_plan.json"), plan);
836
1448
  writeJson(path.join(dd, "batch_plan.json"), batchPlan);
1449
+ emitInitProgress(workspace, "batch_plan", "success");
837
1450
  const episodeResultsDir = path.join(dd, "episode_results");
838
1451
  const batchResultsDir = path.join(dd, "batch_results");
839
1452
  fs.mkdirSync(episodeResultsDir, { recursive: true });
@@ -900,6 +1513,7 @@ export async function commandInit(opts) {
900
1513
  episode_total: totalEpisodes,
901
1514
  batch_total: totalBatches,
902
1515
  });
1516
+ emitInitProgress(workspace, "batch_extract");
903
1517
  // Persist each batch the moment it finishes (inside the worker), NOT after the
904
1518
  // whole pool drains. Per-unit files have no shared index, so concurrent writes
905
1519
  // are safe — and batch_results/ fills up live, so a crash/kill mid-run keeps
@@ -911,10 +1525,12 @@ export async function commandInit(opts) {
911
1525
  const errorPath = batchErrorPath(batchResultsDir, batch);
912
1526
  if (exists(errorPath))
913
1527
  deletePath(errorPath);
1528
+ emitInitProgress(workspace, "batch_extract");
914
1529
  return true;
915
1530
  }
916
1531
  catch (error) {
917
1532
  writeBatchFailure(batchResultsDir, batch, error);
1533
+ emitInitProgress(workspace, "batch_extract");
918
1534
  return false;
919
1535
  }
920
1536
  });
@@ -927,6 +1543,15 @@ export async function commandInit(opts) {
927
1543
  // reported; assembling them is left to an explicit re-run.
928
1544
  const results = [];
929
1545
  const incompleteEpisodes = [];
1546
+ updateRunState(workspace, {
1547
+ status: "init_running",
1548
+ init_stage: "episode_merge",
1549
+ episode_total: totalEpisodes,
1550
+ batch_total: totalBatches,
1551
+ batch_completed: doneBatches,
1552
+ batch_failed: erroredBatches,
1553
+ });
1554
+ emitInitProgress(workspace, "episode_merge");
930
1555
  try {
931
1556
  // Assemble every complete episode (incomplete ones are reported and skipped).
932
1557
  for (const episode of asList(plan["episodes"])) {
@@ -946,9 +1571,12 @@ export async function commandInit(opts) {
946
1571
  validateEpisodeExtractionQuality(sourceText, episode, merged);
947
1572
  results.push(merged); // complete episodes only; carries `episode` for the write below
948
1573
  }
1574
+ emitInitProgress(workspace, "episode_merge", incompleteEpisodes.length > 0 ? "warning" : "success");
949
1575
  // Episode-level synopsis (reduce-1): resolve each result's synopsis in place
950
1576
  // (passthrough / reuse / skip / concurrent LLM stitch). Reads prior `syn`
951
1577
  // BEFORE the persist loop below overwrites ep_NNN.json.
1578
+ updateRunState(workspace, { status: "init_running", init_stage: "episode_synopsis" });
1579
+ emitInitProgress(workspace, "episode_synopsis");
952
1580
  summaryDegradedEpisodes.push(...await resolveEpisodeSynopses(provider, results, {
953
1581
  resultsDir: episodeResultsDir,
954
1582
  resummarize,
@@ -964,6 +1592,7 @@ export async function commandInit(opts) {
964
1592
  if (exists(errPath))
965
1593
  deletePath(errPath);
966
1594
  }
1595
+ emitInitProgress(workspace, "episode_synopsis", summaryDegradedEpisodes.length > 0 ? "warning" : "success");
967
1596
  }
968
1597
  catch (exc) {
969
1598
  const e = exc;
@@ -989,12 +1618,13 @@ export async function commandInit(opts) {
989
1618
  batch_failed: erroredBatches,
990
1619
  last_error: { title: "INIT INCOMPLETE: Some episodes have unextracted batches", failed_at: checkpointTimestamp() },
991
1620
  });
1621
+ emitInitProgress(workspace, "batch_extract", "error");
992
1622
  const report = {
993
1623
  title: "INIT INCOMPLETE: Some episodes have unextracted batches",
994
1624
  result: [
995
1625
  `episodes: ${results.length}/${totalEpisodes} complete`,
996
1626
  `incomplete episodes: ${incompleteEpisodes.join(", ")}`,
997
- `batches: ${doneBatches}/${totalBatches} done, ${erroredBatches} error`,
1627
+ `content progress: ${totalBatches > 0 ? Math.round((doneBatches / totalBatches) * 100) : 0}% done, ${erroredBatches} error`,
998
1628
  ...(titleDegraded.length > 0 ? [`titles degraded (deterministic): ${titleDegraded.join(", ")}`] : []),
999
1629
  `provider: ${providerName}`,
1000
1630
  ],
@@ -1007,16 +1637,16 @@ export async function commandInit(opts) {
1007
1637
  path.join(dd, "run_state.json"),
1008
1638
  ],
1009
1639
  next: [
1010
- "Run `direct status` to see per-episode batch state.",
1011
- "Re-run one episode with `direct init --episodes <n>`, one batch with `--batches <key>`, or all errors with `--retry-errors`.",
1012
- "Errored batches are left untouched on a plain rerun; select them to retry.",
1640
+ "Run `direct status` to see episode progress.",
1641
+ "Re-run one episode with `direct init --episodes <n>`, or all errors with `--retry-errors`.",
1642
+ "Errored units are left untouched on a plain rerun; select them to retry.",
1013
1643
  ],
1014
1644
  };
1015
1645
  return [report, EXIT_RUNTIME];
1016
1646
  }
1017
1647
  updateRunState(workspace, {
1018
1648
  status: "init_running",
1019
- init_stage: "episode_merge",
1649
+ init_stage: "script_merge",
1020
1650
  episode_total: totalEpisodes,
1021
1651
  episode_completed: results.length,
1022
1652
  incomplete_episodes: [],
@@ -1027,7 +1657,7 @@ export async function commandInit(opts) {
1027
1657
  });
1028
1658
  let script;
1029
1659
  try {
1030
- updateRunState(workspace, { status: "init_running", init_stage: "script_merge" });
1660
+ emitInitProgress(workspace, "script_merge");
1031
1661
  script = mergeEpisodeResults(results, strOf(info["projectName"]) || path.basename(source, path.extname(source)));
1032
1662
  }
1033
1663
  catch (exc) {
@@ -1043,6 +1673,7 @@ export async function commandInit(opts) {
1043
1673
  }
1044
1674
  try {
1045
1675
  updateRunState(workspace, { status: "init_running", init_stage: "asset_curation" });
1676
+ emitInitProgress(workspace, "asset_curation");
1046
1677
  // Location merge is deterministic-only: actor/prop pruning runs from scene
1047
1678
  // counts and every location is kept. The previous provider location-merge
1048
1679
  // call was removed (single large streaming request kept dropping the gateway
@@ -1063,8 +1694,10 @@ export async function commandInit(opts) {
1063
1694
  updates: { episode_completed: results.length },
1064
1695
  });
1065
1696
  }
1697
+ emitInitProgress(workspace, "asset_curation", "success");
1066
1698
  try {
1067
1699
  updateRunState(workspace, { status: "init_running", init_stage: "metadata_extract" });
1700
+ emitInitProgress(workspace, "metadata_extract");
1068
1701
  let metadata = provider.extractMetadata ? await provider.extractMetadata(sourceText, script) : {};
1069
1702
  if (!isDict(metadata))
1070
1703
  metadata = {};
@@ -1094,13 +1727,15 @@ export async function commandInit(opts) {
1094
1727
  updates: { episode_completed: results.length },
1095
1728
  });
1096
1729
  }
1730
+ emitInitProgress(workspace, "metadata_extract", "success");
1097
1731
  // Whole-script overview (reduce-2). Soft: a non-essential concept enhancement,
1098
1732
  // the opposite of batch extraction's hard-fail policy — failures degrade the
1099
1733
  // overview (recorded as summary_degraded + a sanitized reason) and NEVER block init.
1100
1734
  let summaryFullDegraded = false;
1101
1735
  let summaryDegradeReason = "";
1102
1736
  try {
1103
- updateRunState(workspace, { status: "init_running", init_stage: "summary" });
1737
+ updateRunState(workspace, { status: "init_running", init_stage: "script_synopsis" });
1738
+ emitInitProgress(workspace, "script_synopsis");
1104
1739
  const summary = await buildScriptSynopsis(provider, script, {
1105
1740
  dd,
1106
1741
  thresholdChars: summaryThresholdChars,
@@ -1120,7 +1755,15 @@ export async function commandInit(opts) {
1120
1755
  const summaryDegraded = summaryFullDegraded || summaryDegradedEpisodes.length > 0;
1121
1756
  const scriptPath = path.join(dd, "script.initial.json");
1122
1757
  writeJson(scriptPath, script);
1758
+ updateRunState(workspace, {
1759
+ summary_skipped: skipSummary,
1760
+ summary_degraded: summaryDegraded,
1761
+ summary_degraded_episodes: summaryDegradedEpisodes,
1762
+ summary_degraded_reason: summaryDegradeReason || null,
1763
+ });
1764
+ emitInitProgress(workspace, "script_synopsis", summaryDegraded ? "warning" : skipSummary ? "skipped" : "success");
1123
1765
  updateRunState(workspace, { status: "init_running", init_stage: "validate" });
1766
+ emitInitProgress(workspace, "validate");
1124
1767
  let validation;
1125
1768
  try {
1126
1769
  validation = validateScript(workspace, scriptPath);
@@ -1166,6 +1809,7 @@ export async function commandInit(opts) {
1166
1809
  summary_degraded_reason: summaryDegradeReason || null,
1167
1810
  last_error: null,
1168
1811
  });
1812
+ emitInitProgress(workspace, "validate", passed ? "success" : "warning");
1169
1813
  const title = passed
1170
1814
  ? "INIT COMPLETE: Initial script ready"
1171
1815
  : "INIT NEEDS AGENT: Initial script written with repair issues";
@@ -1180,7 +1824,7 @@ export async function commandInit(opts) {
1180
1824
  `validation: ${passed ? "passed" : "needs repair"}`,
1181
1825
  `provider: ${providerName}`,
1182
1826
  `episodes: ${results.length}/${totalEpisodes} complete`,
1183
- `batches: ${doneBatches}/${totalBatches} done (reused ${reusedBatchCount})`,
1827
+ `content progress: ${totalBatches > 0 ? Math.round((doneBatches / totalBatches) * 100) : 0}% done`,
1184
1828
  ...(titleDegraded.length > 0 ? [`titles degraded (deterministic): ${titleDegraded.join(", ")}`] : []),
1185
1829
  ...(summaryLine ? [summaryLine] : []),
1186
1830
  ],
@@ -1219,7 +1863,8 @@ export function commandStatus(opts) {
1219
1863
  const dd = directDir(workspace);
1220
1864
  const episodePlanPath = path.join(dd, "episode_plan.json");
1221
1865
  const batchPlanPath = path.join(dd, "batch_plan.json");
1222
- if (!exists(episodePlanPath) || !exists(batchPlanPath)) {
1866
+ const hasTrace = exists(path.join(workspace, "source.txt")) || Object.keys(readRunState(workspace)).length > 0;
1867
+ if (!hasTrace && (!exists(episodePlanPath) || !exists(batchPlanPath))) {
1223
1868
  throw new CliError("STATUS BLOCKED: Plan not found", "Plan not found.", {
1224
1869
  exitCode: EXIT_INPUT,
1225
1870
  required: ["episode_plan.json and batch_plan.json"],
@@ -1227,90 +1872,26 @@ export function commandStatus(opts) {
1227
1872
  nextSteps: ["Run scriptctl direct init first."],
1228
1873
  });
1229
1874
  }
1230
- const episodes = asList(readJson(episodePlanPath)["episodes"]);
1231
- const batches = asList(readJson(batchPlanPath)["batches"]);
1232
- const batchResultsDir = path.join(dd, "batch_results");
1233
- const byEp = new Map();
1234
- let doneTotal = 0;
1235
- let errorTotal = 0;
1236
- for (const batch of batches) {
1237
- const epNum = Number(batch["episode"]);
1238
- if (!byEp.has(epNum))
1239
- byEp.set(epNum, { total: 0, done: 0, error: 0, pending: 0, errorKeys: [], reasons: new Set() });
1240
- const st = byEp.get(epNum);
1241
- st.total++;
1242
- const state = classifyBatch(batchResultsDir, batch);
1243
- if (state === "done") {
1244
- st.done++;
1245
- doneTotal++;
1246
- continue;
1247
- }
1248
- if (state === "error") {
1249
- st.error++;
1250
- errorTotal++;
1251
- st.errorKeys.push(batchResultKey(batch));
1252
- try {
1253
- const err = readJson(batchErrorPath(batchResultsDir, batch));
1254
- st.reasons.add(strOf(err["error_code"] || err["error_type"] || "Error"));
1255
- }
1256
- catch {
1257
- st.reasons.add("Error");
1258
- }
1259
- }
1260
- else {
1261
- st.pending++;
1262
- }
1263
- }
1264
- const lines = [];
1265
- let completeEpisodes = 0;
1266
- const errorEpisodes = [];
1267
- for (const ep of episodes) {
1268
- const epNum = Number(ep["episode"]);
1269
- const st = byEp.get(epNum) ?? { total: 0, done: 0, error: 0, pending: 0, errorKeys: [], reasons: new Set() };
1270
- let label;
1271
- if (st.total > 0 && st.done === st.total) {
1272
- label = "done";
1273
- completeEpisodes++;
1274
- }
1275
- else if (st.error > 0) {
1276
- label = `ERROR (${st.done}/${st.total} done, ${st.error} err: ${[...st.reasons].join(",")}) [${st.errorKeys.join(",")}]`;
1277
- errorEpisodes.push(epNum);
1278
- }
1279
- else {
1280
- label = `pending (${st.done}/${st.total} done)`;
1281
- }
1282
- lines.push(`${episodeResultKey(ep)} ${label}`);
1283
- }
1284
- // Surface degraded titles (deterministic fallback) recorded by init — they are
1285
- // not a failure but the agent should know.
1286
- const state = readRunState(workspace);
1287
- const titleDeg = isDict(state["title_degraded"]) ? state["title_degraded"] : null;
1288
- const result = [
1289
- `episodes: ${completeEpisodes}/${episodes.length} done`,
1290
- `batches: ${doneTotal}/${batches.length} done, ${errorTotal} error`,
1291
- `error episodes: ${errorEpisodes.length === 0 ? "-" : errorEpisodes.join(", ")}`,
1292
- ];
1293
- if (titleDeg && Number(titleDeg["count"] ?? 0) > 0) {
1294
- result.push(`titles degraded (deterministic): ${asList(titleDeg["episodes"]).join(", ")}`);
1295
- }
1296
- // Surface synopsis state recorded by init: skipped or degraded summaries mean
1297
- // the overview / some episode synopses are missing or fell back to joined
1298
- // beats — an agent gating on status should know it isn't a complete summary.
1299
- // Same formatter as init's report so the two never word it differently.
1300
- const summaryLine = formatSummaryStatusLine(Boolean(state["summary_skipped"]), Boolean(state["summary_degraded"]), asList(state["summary_degraded_episodes"]).map((n) => Number(n)));
1301
- if (summaryLine)
1302
- result.push(summaryLine);
1303
- result.push(...lines);
1304
- const allDone = completeEpisodes === episodes.length;
1875
+ const snapshot = buildDirectStatusSnapshot(workspace);
1876
+ const result = renderDirectStatusResult(snapshot);
1877
+ const blockingStatuses = new Set(["error", "pending", "running"]);
1878
+ const allDone = !snapshot.stages.some((stage) => blockingStatuses.has(stage.status));
1879
+ const firstErrorEpisode = snapshot.episodes.find((episode) => episode.status === "error");
1305
1880
  const report = {
1306
1881
  title: "DIRECT STATUS",
1307
1882
  result,
1308
- artifacts: [batchResultsDir, path.join(dd, "episode_results"), path.join(dd, "run_state.json")],
1309
- next: errorEpisodes.length > 0
1310
- ? [`Re-run an errored episode with: direct init --episodes ${errorEpisodes[0]} (or --batches <key>, or --retry-errors).`]
1883
+ artifacts: [path.join(dd, "batch_results"), path.join(dd, "episode_results"), path.join(dd, "run_state.json")],
1884
+ overall_status: snapshot.overall_status,
1885
+ running_stage: snapshot.running_stage,
1886
+ progress_percent: snapshot.progress_percent,
1887
+ stages: snapshot.stages,
1888
+ episodes: snapshot.episodes,
1889
+ batches: snapshot.batches,
1890
+ next: firstErrorEpisode
1891
+ ? [`Re-run an errored episode with: direct init --episodes ${firstErrorEpisode.episode} (or --retry-errors).`]
1311
1892
  : allDone
1312
1893
  ? ["All episodes complete."]
1313
- : ["Pending batches remain — re-run: direct init (resume) or --episodes <N>."],
1894
+ : ["Pending work remains — re-run: direct init (resume) or --episodes <N>."],
1314
1895
  };
1315
1896
  // Non-zero when the workspace is not fully extracted, so automation can gate
1316
1897
  // on the exit code instead of scraping stdout.