@lingjingai/scriptctl 0.28.0 → 0.28.1

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.
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_CONCURRENCY, DEFAULT_MODEL, DEFAULT_PROVIDER, DEFAULT_SUMMARY_GROUP_SIZE, DEFAULT_SUMMARY_REDUCE_THRESHOLD_CHARS, EXIT_INPUT, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, SUPPORTED_EXTS, deletePath, directDir, exists, fmtId, readJson, readText, sha256Text, writeJson, } from "../common.js";
4
- import { compactBatchResult, compactEpisodeResult, buildBatchPlan, buildEpisodePlan, DEFAULT_TITLE_CHUNK_SIZE, chunkEpisodesNeedingTitles, applyEpisodeTitlesToPlan, cleanSynopsisList, episodeTitleResponseMap, extractBatchWithRecovery, mergeEpisodeResults, normalizeEpisodeResult, planSynopsisReduction, readCarriedSynopsis, sortDeep, uniqueAdd, validateEpisodeExtractionQuality, _md_push_asset, curateScriptAssets, applyMetadataToScript, } from "../domain/direct-core.js";
4
+ import { compactBatchResult, compactEpisodeResult, buildBatchPlan, buildEpisodePlan, DEFAULT_TITLE_CHUNK_SIZE, chunkEpisodesNeedingTitles, applyEpisodeTitlesToPlan, cleanSynopsisList, episodeTitleResponseMap, extractBatchWithRecovery, mergeEpisodeResultsWithReport, normalizeEpisodeResult, planSynopsisReduction, readCarriedSynopsis, sortDeep, uniqueAdd, validateEpisodeExtractionQuality, _md_push_asset, curateScriptAssets, applyMetadataToScript, } from "../domain/direct-core.js";
5
5
  import { validateScript } from "../domain/script/validate.js";
6
6
  import { makeProvider, providerCapabilities } from "../infra/providers.js";
7
7
  import { makeSourceManifest, prepareSource, } from "../infra/converters.js";
@@ -211,13 +211,11 @@ function statusFromBatchState(state) {
211
211
  function stageSnapshot(name, status, completed, total, message) {
212
212
  const boundedTotal = Math.max(0, total);
213
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
214
  return {
216
215
  name,
217
216
  status,
218
217
  completed: boundedCompleted,
219
218
  total: boundedTotal,
220
- percent,
221
219
  pending: status === "skipped" ? 0 : Math.max(0, boundedTotal - boundedCompleted),
222
220
  warning: status === "warning" ? 1 : 0,
223
221
  error: status === "error" ? 1 : 0,
@@ -229,6 +227,17 @@ function fileStage(name, filePath, runningStage) {
229
227
  return stageSnapshot(name, "success", 1, 1);
230
228
  return stageSnapshot(name, runningStage === name ? "running" : "pending", 0, 1);
231
229
  }
230
+ function orderedFileStage(name, filePath, runningStage, runStatus) {
231
+ if (!canTrustStageArtifacts(name, runningStage, runStatus)) {
232
+ return stageSnapshot(name, "pending", 0, 1);
233
+ }
234
+ return fileStage(name, filePath, runningStage ?? "");
235
+ }
236
+ function canTrustStageArtifacts(name, runningStage, runStatus) {
237
+ if (runStatus !== "init_running" || !runningStage)
238
+ return true;
239
+ return stageOrder(name) <= stageOrder(runningStage);
240
+ }
232
241
  function readJsonFile(filePath, fallback) {
233
242
  if (!exists(filePath))
234
243
  return fallback;
@@ -296,15 +305,14 @@ function userStageRows(stages) {
296
305
  }
297
306
  const rows = [];
298
307
  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);
308
+ const total = items.reduce((sum, item) => sum + item.total, 0);
309
+ const completed = items.reduce((sum, item) => sum + (item.status === "skipped" ? item.total : item.completed), 0);
301
310
  const status = items.reduce((max, item) => statusPriority(item.status) > statusPriority(max) ? item.status : max, "success");
302
311
  rows.push({
303
312
  name: items[0].name,
304
313
  status,
305
314
  completed: Math.round(completed),
306
315
  total,
307
- percent: total > 0 ? Math.round((completed / total) * 100) : 0,
308
316
  pending: items.reduce((sum, item) => sum + item.pending, 0),
309
317
  warning: items.reduce((sum, item) => sum + item.warning, 0),
310
318
  error: items.reduce((sum, item) => sum + item.error, 0),
@@ -313,31 +321,33 @@ function userStageRows(stages) {
313
321
  }
314
322
  return rows;
315
323
  }
316
- function stageWeight(name) {
324
+ function stageOrder(name) {
317
325
  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;
326
+ case "source_prepare": return 0;
327
+ case "episode_plan": return 1;
328
+ case "episode_titles": return 2;
329
+ case "batch_plan": return 3;
330
+ case "batch_extract": return 4;
331
+ case "episode_merge": return 5;
332
+ case "episode_synopsis": return 6;
325
333
  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;
334
+ case "asset_curation": return 8;
335
+ case "metadata_extract": return 9;
336
+ case "script_synopsis": return 10;
337
+ case "validate": return 11;
330
338
  }
331
339
  }
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;
340
+ function stageRatio(stage) {
341
+ if (stage.status === "success" || stage.status === "skipped")
342
+ return 1;
343
+ if (stage.total <= 0)
344
+ return 0;
345
+ return Math.max(0, Math.min(1, stage.completed / stage.total));
346
+ }
347
+ function stageCountText(stage) {
348
+ if (stage.status === "skipped")
349
+ return "-";
350
+ return `${stage.completed}/${stage.total}`;
341
351
  }
342
352
  function useProgressColor() {
343
353
  return Boolean(process.stderr.isTTY) && process.env.NO_COLOR === undefined;
@@ -363,7 +373,7 @@ function bold(text) {
363
373
  }
364
374
  function progressBar(stage) {
365
375
  const width = 24;
366
- const ratio = stage.percent / 100;
376
+ const ratio = stageRatio(stage);
367
377
  const filled = Math.round(ratio * width);
368
378
  const bar = `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
369
379
  return colorStatus(stage.status, bar);
@@ -387,13 +397,12 @@ function statusMark(stage) {
387
397
  }
388
398
  function renderProgressLine(stage) {
389
399
  const status = colorStatus(stage.status, stage.status.padEnd(7));
390
- const percent = `${stage.percent}%`;
391
400
  const bits = [
392
401
  statusMark(stage),
393
402
  bold(stageLabel(stage.name)),
394
403
  status,
395
404
  progressBar(stage),
396
- `${percent} ${stageUnit(stage.name)}`,
405
+ `${stageCountText(stage)} ${stageUnit(stage.name)}`,
397
406
  ];
398
407
  if (stage.error > 0)
399
408
  bits.push(ansi(31, `error ${stage.error}`));
@@ -406,7 +415,7 @@ function renderProgressLine(stage) {
406
415
  return bits.join(" ");
407
416
  }
408
417
  function renderStageDetail(stage) {
409
- const counts = [`${stage.percent}%`];
418
+ const counts = [stageCountText(stage)];
410
419
  if (stage.error > 0)
411
420
  counts.push(ansi(31, `error ${stage.error}`));
412
421
  if (stage.warning > 0)
@@ -416,9 +425,8 @@ function renderStageDetail(stage) {
416
425
  return ` ${dim("stage")} ${stageLabel(stage.name)} ${colorStatus(stage.status, statusText(stage.status))} ${counts.join(", ")}`;
417
426
  }
418
427
  function renderEpisodeDetail(episode) {
419
- const percent = episode.batches_total > 0 ? Math.round((episode.batches_done / episode.batches_total) * 100) : 0;
420
428
  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}`;
429
+ return ` ${dim("episode")} ${episode.episode_id} ${colorStatus(episode.status, statusText(episode.status))} ${episode.batches_done}/${episode.batches_total}, story ${colorStatus(episode.synopsis_status, statusText(episode.synopsis_status))}${errors}`;
422
430
  }
423
431
  function renderBatchDetail(batch) {
424
432
  const reason = batch.error_code || batch.error_type || batch.message;
@@ -452,11 +460,11 @@ function renderProgressFrame() {
452
460
  }
453
461
  function progressStageWithStatus(stage, status) {
454
462
  if (status === "success")
455
- return { ...stage, status, completed: stage.total, percent: 100, pending: 0, warning: 0, error: 0 };
463
+ return { ...stage, status, completed: stage.total, pending: 0, warning: 0, error: 0 };
456
464
  if (status === "warning")
457
- return { ...stage, status, completed: stage.total, percent: 100, pending: 0, warning: Math.max(1, stage.warning), error: 0 };
465
+ return { ...stage, status, completed: stage.total, pending: 0, warning: Math.max(1, stage.warning), error: 0 };
458
466
  if (status === "skipped")
459
- return { ...stage, status, percent: 100, pending: 0, warning: 0, error: 0 };
467
+ return { ...stage, status, completed: stage.total, pending: 0, warning: 0, error: 0 };
460
468
  if (status === "error")
461
469
  return { ...stage, status, error: Math.max(1, stage.error) };
462
470
  return { ...stage, status };
@@ -534,7 +542,6 @@ function emitInitProgress(workspace, stageName, fallbackStatus = "running") {
534
542
  status: fallbackStatus,
535
543
  completed: fallbackStatus === "success" ? 1 : 0,
536
544
  total: 1,
537
- percent: fallbackStatus === "success" ? 100 : 0,
538
545
  pending: fallbackStatus === "running" || fallbackStatus === "pending" ? 1 : 0,
539
546
  warning: fallbackStatus === "warning" ? 1 : 0,
540
547
  error: fallbackStatus === "error" ? 1 : 0,
@@ -556,11 +563,14 @@ function buildDirectStatusSnapshot(workspace) {
556
563
  const batches = asList(batchPlan["batches"]);
557
564
  const state = readRunState(workspace);
558
565
  const rawRunningStage = strOf(state["init_stage"]);
559
- const runningStage = rawRunningStage && rawRunningStage !== "complete" ? rawRunningStage : null;
566
+ const runningStage = rawRunningStage && rawRunningStage !== "complete" && isDirectStageName(rawRunningStage) ? rawRunningStage : null;
560
567
  const runStatus = strOf(state["status"]);
561
568
  const failedStage = runStatus === "init_failed" ? runningStage : null;
562
569
  const batchResultsDir = path.join(dd, "batch_results");
563
570
  const episodeResultsDir = path.join(dd, "episode_results");
571
+ const trustEpisodeMergeArtifacts = canTrustStageArtifacts("episode_merge", runningStage, runStatus);
572
+ const trustEpisodeSynopsisArtifacts = canTrustStageArtifacts("episode_synopsis", runningStage, runStatus);
573
+ const trustScriptSynopsisArtifacts = canTrustStageArtifacts("script_synopsis", runningStage, runStatus);
564
574
  const batchesByEpisode = new Map();
565
575
  const batchDetails = [];
566
576
  let doneBatches = 0;
@@ -601,7 +611,9 @@ function buildDirectStatusSnapshot(workspace) {
601
611
  batchesByEpisode.get(detail.episode).push(detail);
602
612
  }
603
613
  const pendingBatches = batches.length - doneBatches - errorBatches;
604
- const degradedSynopsisEpisodes = new Set(asList(state["summary_degraded_episodes"]).map((n) => Number(n)));
614
+ const degradedSynopsisEpisodes = trustEpisodeSynopsisArtifacts
615
+ ? new Set(asList(state["summary_degraded_episodes"]).map((n) => Number(n)))
616
+ : new Set();
605
617
  const episodeSnapshots = [];
606
618
  let completeEpisodes = 0;
607
619
  let episodeResults = 0;
@@ -613,10 +625,10 @@ function buildDirectStatusSnapshot(workspace) {
613
625
  const batchesError = epBatches.filter((b) => b.status === "error").length;
614
626
  const batchesPending = epBatches.length - batchesDone - batchesError;
615
627
  const epResultPath = episodeResultPath(episodeResultsDir, episode);
616
- const hasEpisodeResult = readableJsonFile(epResultPath);
628
+ const hasEpisodeResult = trustEpisodeMergeArtifacts && readableJsonFile(epResultPath);
617
629
  if (hasEpisodeResult)
618
630
  episodeResults++;
619
- const synopsis = readEpisodeSynopsis(episodeResultsDir, episode);
631
+ const synopsis = trustEpisodeSynopsisArtifacts ? readEpisodeSynopsis(episodeResultsDir, episode) : null;
620
632
  if (synopsis)
621
633
  episodeSynopses++;
622
634
  const status = batchesError > 0
@@ -694,22 +706,23 @@ function buildDirectStatusSnapshot(workspace) {
694
706
  ...stageSnapshot("episode_synopsis", episodeSynopsisStatus, episodeSynopses, episodes.length),
695
707
  warning: synopsisWarning,
696
708
  };
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 ?? "");
709
+ const scriptStage = orderedFileStage("script_merge", path.join(dd, "script.initial.json"), runningStage, runStatus);
710
+ const curationStage = orderedFileStage("asset_curation", path.join(dd, "asset_curation.json"), runningStage, runStatus);
711
+ const metadataStage = orderedFileStage("metadata_extract", path.join(dd, "asset_metadata.json"), runningStage, runStatus);
700
712
  const synopsisPath = path.join(dd, "synopsis.json");
701
713
  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)
714
+ const hasScriptSynopsis = trustScriptSynopsisArtifacts && exists(synopsisPath);
715
+ const scriptSynopsisStatus = hasScriptSynopsis
703
716
  ? Boolean(state["summary_degraded"]) ? "warning" : "success"
704
- : Boolean(state["summary_skipped"])
717
+ : trustScriptSynopsisArtifacts && Boolean(state["summary_skipped"])
705
718
  ? "skipped"
706
719
  : runningStage === "script_synopsis"
707
720
  ? "running"
708
721
  : "pending";
709
- const scriptSynopsisStage = stageSnapshot("script_synopsis", scriptSynopsisStatus, exists(synopsisPath) ? 1 : 0, 1, summaryLine ?? "");
722
+ const scriptSynopsisStage = stageSnapshot("script_synopsis", scriptSynopsisStatus, hasScriptSynopsis ? 1 : 0, 1, summaryLine ?? "");
710
723
  const validationPath = path.join(dd, "validation.json");
711
- let validationStage = fileStage("validate", validationPath, runningStage ?? "");
712
- if (exists(validationPath)) {
724
+ let validationStage = orderedFileStage("validate", validationPath, runningStage, runStatus);
725
+ if (exists(validationPath) && !(runStatus === "init_running" && runningStage && stageOrder("validate") > stageOrder(runningStage))) {
713
726
  const validation = readJson(validationPath);
714
727
  validationStage = stageSnapshot("validate", Boolean(validation["passed"]) ? "success" : "warning", 1, 1);
715
728
  }
@@ -740,7 +753,6 @@ function buildDirectStatusSnapshot(workspace) {
740
753
  return {
741
754
  overall_status: overallStatus,
742
755
  running_stage: runningStage,
743
- progress_percent: snapshotProgressPercent(stages),
744
756
  stages,
745
757
  episodes: episodeSnapshots,
746
758
  batches: batchDetails,
@@ -750,18 +762,17 @@ function renderDirectStatusResult(snapshot) {
750
762
  const completedEpisodes = snapshot.episodes.filter((episode) => episode.status === "success").length;
751
763
  const result = [
752
764
  "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: 等待中",
765
+ `overall: ${statusText(snapshot.overall_status)}; current: ${snapshot.running_stage && isDirectStageName(snapshot.running_stage) ? stageLabel(snapshot.running_stage) : "-"}`,
766
+ snapshot.episodes.length > 0 ? `episodes: ${completedEpisodes}/${snapshot.episodes.length} 完成` : "episodes: 等待中",
755
767
  "Stages",
756
768
  ];
757
769
  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}` : ""}`);
770
+ result.push(`${stageLabel(stage.name)}: ${statusText(stage.status)} ${stageCountText(stage)}${stage.error ? `, error ${stage.error}` : ""}${stage.warning ? `, warning ${stage.warning}` : ""}${stage.status === "pending" ? ", 等待中" : ""}${stage.message ? ` - ${stage.message}` : ""}`);
759
771
  }
760
772
  result.push("Episodes");
761
773
  for (const episode of snapshot.episodes) {
762
- const percent = episode.batches_total > 0 ? Math.round((episode.batches_done / episode.batches_total) * 100) : 0;
763
774
  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}`);
775
+ result.push(`${episode.episode_id}: ${statusText(episode.status)} ${episode.batches_done}/${episode.batches_total}, story ${statusText(episode.synopsis_status)}${errors}`);
765
776
  }
766
777
  result.push("Details");
767
778
  const noisyBatches = snapshot.batches.filter((batch) => batch.status !== "success");
@@ -1624,7 +1635,7 @@ async function commandInitImpl(opts) {
1624
1635
  result: [
1625
1636
  `episodes: ${results.length}/${totalEpisodes} complete`,
1626
1637
  `incomplete episodes: ${incompleteEpisodes.join(", ")}`,
1627
- `content progress: ${totalBatches > 0 ? Math.round((doneBatches / totalBatches) * 100) : 0}% done, ${erroredBatches} error`,
1638
+ `content: ${doneBatches}/${totalBatches} done, ${erroredBatches} error`,
1628
1639
  ...(titleDegraded.length > 0 ? [`titles degraded (deterministic): ${titleDegraded.join(", ")}`] : []),
1629
1640
  `provider: ${providerName}`,
1630
1641
  ],
@@ -1656,9 +1667,14 @@ async function commandInitImpl(opts) {
1656
1667
  last_error: null,
1657
1668
  });
1658
1669
  let script;
1670
+ const assetNormalizationPath = path.join(dd, "asset_normalization.json");
1671
+ let assetNormalization = null;
1659
1672
  try {
1660
1673
  emitInitProgress(workspace, "script_merge");
1661
- script = mergeEpisodeResults(results, strOf(info["projectName"]) || path.basename(source, path.extname(source)));
1674
+ const merged = mergeEpisodeResultsWithReport(results, strOf(info["projectName"]) || path.basename(source, path.extname(source)));
1675
+ script = merged.script;
1676
+ assetNormalization = merged.assetNormalization;
1677
+ writeJson(assetNormalizationPath, assetNormalization);
1662
1678
  }
1663
1679
  catch (exc) {
1664
1680
  const e = exc;
@@ -1815,6 +1831,12 @@ async function commandInitImpl(opts) {
1815
1831
  : "INIT NEEDS AGENT: Initial script written with repair issues";
1816
1832
  const stats = validation["stats"] ?? {};
1817
1833
  const summaryLine = formatSummaryStatusLine(skipSummary, summaryDegraded, summaryDegradedEpisodes);
1834
+ const normalizationSummary = assetNormalization?.summary ?? {
1835
+ asset_merges: 0,
1836
+ location_states_from_names: 0,
1837
+ location_states_from_time: 0,
1838
+ review_items: 0,
1839
+ };
1818
1840
  const report = {
1819
1841
  title,
1820
1842
  result: [
@@ -1824,7 +1846,8 @@ async function commandInitImpl(opts) {
1824
1846
  `validation: ${passed ? "passed" : "needs repair"}`,
1825
1847
  `provider: ${providerName}`,
1826
1848
  `episodes: ${results.length}/${totalEpisodes} complete`,
1827
- `content progress: ${totalBatches > 0 ? Math.round((doneBatches / totalBatches) * 100) : 0}% done`,
1849
+ `content: ${doneBatches}/${totalBatches} done`,
1850
+ `normalization: merges=${normalizationSummary.asset_merges}, states=${normalizationSummary.location_states_from_names + normalizationSummary.location_states_from_time}, review=${normalizationSummary.review_items}`,
1828
1851
  ...(titleDegraded.length > 0 ? [`titles degraded (deterministic): ${titleDegraded.join(", ")}`] : []),
1829
1852
  ...(summaryLine ? [summaryLine] : []),
1830
1853
  ],
@@ -1835,6 +1858,7 @@ async function commandInitImpl(opts) {
1835
1858
  path.join(dd, "batch_plan.json"),
1836
1859
  batchResultsDir,
1837
1860
  episodeResultsDir,
1861
+ assetNormalizationPath,
1838
1862
  path.join(dd, "asset_curation.json"),
1839
1863
  path.join(dd, "asset_metadata.json"),
1840
1864
  path.join(dd, "synopsis.json"),
@@ -1883,7 +1907,6 @@ export function commandStatus(opts) {
1883
1907
  artifacts: [path.join(dd, "batch_results"), path.join(dd, "episode_results"), path.join(dd, "run_state.json")],
1884
1908
  overall_status: snapshot.overall_status,
1885
1909
  running_stage: snapshot.running_stage,
1886
- progress_percent: snapshot.progress_percent,
1887
1910
  stages: snapshot.stages,
1888
1911
  episodes: snapshot.episodes,
1889
1912
  batches: snapshot.batches,
@@ -2114,6 +2137,24 @@ function renderAssetCurationSummary(curation) {
2114
2137
  }
2115
2138
  return lines;
2116
2139
  }
2140
+ function renderAssetNormalizationSummary(normalization) {
2141
+ const summary = isDict(normalization["summary"]) ? normalization["summary"] : {};
2142
+ const lines = [
2143
+ `normalization: ` +
2144
+ `merges ${summary["asset_merges"] ?? 0}, ` +
2145
+ `location-states ${(Number(summary["location_states_from_names"] ?? 0) + Number(summary["location_states_from_time"] ?? 0))}, ` +
2146
+ `review ${summary["review_items"] ?? 0}`,
2147
+ ];
2148
+ for (const decision of asList(normalization["decisions"])) {
2149
+ if (!isDict(decision))
2150
+ continue;
2151
+ const action = strOf(decision["action"]);
2152
+ if (action !== "canonical_merge" && action !== "location_state_from_name" && action !== "location_state_from_time")
2153
+ continue;
2154
+ lines.push(`normalization ${action}: ${decision["from"] || "-"} -> ${decision["into"] || "-"}${decision["state"] ? ` state=${decision["state"]}` : ""} — ${decision["reason"] || "-"}`);
2155
+ }
2156
+ return lines;
2157
+ }
2117
2158
  // Collect batch extraction failures (batch_results/<key>.error.json) keyed by
2118
2159
  // episode. Shared by both review views — these are direct-stage failures the
2119
2160
  // final-script `issues` query can't see.
@@ -2208,6 +2249,12 @@ export function commandReview(opts) {
2208
2249
  // (validation issues recomputed live, plus batch extraction failures).
2209
2250
  const lines = [];
2210
2251
  const curationPath = path.join(dd, "asset_curation.json");
2252
+ const normalizationPath = path.join(dd, "asset_normalization.json");
2253
+ if (exists(normalizationPath)) {
2254
+ const normalization = readJson(normalizationPath);
2255
+ if (isDict(normalization))
2256
+ lines.push(...renderAssetNormalizationSummary(normalization));
2257
+ }
2211
2258
  if (exists(curationPath)) {
2212
2259
  const curation = readJson(curationPath);
2213
2260
  if (isDict(curation))
@@ -2236,7 +2283,7 @@ export function commandReview(opts) {
2236
2283
  const report = {
2237
2284
  title: "DIRECT REVIEW",
2238
2285
  result: lines.length > 0 ? lines : ["No issues, curation decisions, or batch failures to review."],
2239
- artifacts: [scriptPath, path.join(dd, "asset_curation.json"), batchResultsDir],
2286
+ artifacts: [scriptPath, normalizationPath, path.join(dd, "asset_curation.json"), batchResultsDir],
2240
2287
  next: [
2241
2288
  "Run `direct review --episode <n>` for source↔extract alignment.",
2242
2289
  "Edit with `--draft` (e.g. `scriptctl rename … --draft`), then validate/export.",