@lingjingai/scriptctl 0.10.3 → 0.11.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.
@@ -1,7 +1,7 @@
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, DIRECT_CONTRACT_VERSION, EXIT_INPUT, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, SUPPORTED_EXTS, deletePath, deleteTree, directDir, exists, readJson, readText, sha256Text, writeJson, } from "../common.js";
4
- import { compactBatchResult, compactEpisodeResult, buildBatchPlan, buildEpisodePlan, enrichEpisodePlanTitles, extractBatchWithRecovery, mergeEpisodeResults, normalizeEpisodeResult, normalizeInt, recoverBatchFromSource, uniqueAdd, validateBatchExtractionQuality, validateEpisodeExtractionQuality, _md_push_asset, curateScriptAssets, applyMetadataToScript, } from "../domain/direct-core.js";
3
+ import { CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_CONCURRENCY, DEFAULT_MODEL, DEFAULT_PROVIDER, DIRECT_CONTRACT_VERSION, EXIT_INPUT, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, PARSE_MD_SPEC, REVIEW_TARGETS, SUPPORTED_EXTS, deletePath, deleteTree, directDir, exists, fmtId, readJson, readText, sha256Text, writeJson, } from "../common.js";
4
+ import { compactBatchResult, compactEpisodeResult, buildBatchPlan, buildEpisodePlan, enrichEpisodePlanTitles, extractBatchWithRecovery, mergeEpisodeResults, normalizeEpisodeResult, normalizeInt, parseMarkdownBatch, recoverBatchFromSource, uniqueAdd, validateBatchExtractionQuality, validateEpisodeExtractionQuality, _md_push_asset, curateScriptAssets, applyMetadataToScript, } from "../domain/direct-core.js";
5
5
  import { validateScript } from "../domain/script-core.js";
6
6
  import { makeProvider } from "../infra/providers.js";
7
7
  import { makeSourceManifest, prepareSource, } from "../infra/converters.js";
@@ -66,6 +66,69 @@ function failureSignature(items) {
66
66
  out.sort();
67
67
  return out;
68
68
  }
69
+ export function addInspectedTarget(workspace, target) {
70
+ const state = readRunState(workspace);
71
+ const targets = [];
72
+ for (const item of asList(state["inspected_targets"])) {
73
+ const s = strOf(item);
74
+ if (s)
75
+ targets.push(s);
76
+ }
77
+ if (!targets.includes(target))
78
+ targets.push(target);
79
+ const missing = [];
80
+ for (const t of REVIEW_TARGETS) {
81
+ if (!targets.includes(t))
82
+ missing.push(t);
83
+ }
84
+ const reviewStatus = missing.length === 0 ? "reviewed" : "in_progress";
85
+ return updateRunState(workspace, {
86
+ inspected_targets: targets,
87
+ review_status: reviewStatus,
88
+ review_missing: missing,
89
+ last_inspect_target: target,
90
+ });
91
+ }
92
+ export function markPatched(workspace, count) {
93
+ const state = readRunState(workspace);
94
+ const patchCount = Number(state["patch_count"] ?? 0) + count;
95
+ return updateRunState(workspace, {
96
+ patch_count: patchCount,
97
+ review_status: "patched",
98
+ review_missing: [],
99
+ });
100
+ }
101
+ export function markMetadataConfidenceReviewed(workspace, operations) {
102
+ const metadataOps = new Set(["meta.worldview.set", "asset.role.set", "asset.describe"]);
103
+ if (!operations.some((op) => isDict(op) && metadataOps.has(strOf(op["op"]))))
104
+ return;
105
+ const p = path.join(directDir(workspace), "asset_metadata.json");
106
+ if (!exists(p))
107
+ return;
108
+ let metadata;
109
+ try {
110
+ metadata = readJson(p);
111
+ }
112
+ catch {
113
+ return;
114
+ }
115
+ if (!isDict(metadata) || metadata["confidence"] !== "low")
116
+ return;
117
+ metadata["confidence"] = "medium";
118
+ metadata["confidence_reviewed_at"] = checkpointTimestamp();
119
+ writeJson(p, metadata);
120
+ }
121
+ export function reviewBlockers(state) {
122
+ if (Number(state["patch_count"] ?? 0) > 0)
123
+ return [];
124
+ const inspected = new Set();
125
+ for (const item of asList(state["inspected_targets"])) {
126
+ const s = strOf(item);
127
+ if (s)
128
+ inspected.add(s);
129
+ }
130
+ return REVIEW_TARGETS.filter((t) => !inspected.has(t));
131
+ }
69
132
  // ---------------------------------------------------------------------------
70
133
  // Paths for episode/batch results
71
134
  // ---------------------------------------------------------------------------
@@ -522,7 +585,7 @@ function writeBatchFailure(dir, batch, exc) {
522
585
  function initFailedReport(workspace, opts) {
523
586
  const payload = {
524
587
  status: "init_failed",
525
- command: "import init",
588
+ command: "direct init",
526
589
  init_stage: opts.stage,
527
590
  last_error: { title: opts.title, received: opts.received, failed_at: checkpointTimestamp() },
528
591
  };
@@ -657,7 +720,7 @@ export async function commandInit(opts) {
657
720
  const previousStateBeforeInit = readRunState(workspace);
658
721
  updateRunState(workspace, {
659
722
  status: "init_running",
660
- command: "import init",
723
+ command: "direct init",
661
724
  init_stage: "source_prepare",
662
725
  provider: providerName,
663
726
  model,
@@ -910,11 +973,11 @@ export async function commandInit(opts) {
910
973
  : "INIT INCOMPLETE: Batch extraction failed";
911
974
  const nextSteps = sameFailuresRepeated
912
975
  ? [
913
- "Read failed batch details in draft/scriptctl/direct/batch_results/*.error.json (run_state.json lists failed_batches).",
976
+ "Run direct inspect --target issue to read failed batch details.",
914
977
  "Do not rerun the same init command again until source, batch options, provider, or failed content has changed.",
915
978
  ]
916
979
  : [
917
- "Read failed batches in draft/scriptctl/direct/batch_results/*.error.json (run_state.json lists failed_batches).",
980
+ "Run direct inspect --target issue to review failed batches.",
918
981
  "Rerun the same init once if failures look transient; completed checkpoints will be reused.",
919
982
  ];
920
983
  const failedEpisodeSet = new Set(failedEpisodes);
@@ -957,6 +1020,7 @@ export async function commandInit(opts) {
957
1020
  failure_signature: currentFailureSignature,
958
1021
  failure_streak: failureStreak,
959
1022
  last_error: { title: failureTitle, failed_at: checkpointTimestamp() },
1023
+ exportable: false,
960
1024
  });
961
1025
  const issues = failures.slice(0, 5).map((it) => `${it["batch_id"]} episode ${it["episode"]} part ${it["part"]}: ${it["error_type"]} - ${it["message"]}`);
962
1026
  const report = {
@@ -1147,7 +1211,7 @@ export async function commandInit(opts) {
1147
1211
  const status = passed ? "ready_for_agent" : "needs_agent_repair";
1148
1212
  updateRunState(workspace, {
1149
1213
  status,
1150
- command: "import init",
1214
+ command: "direct init",
1151
1215
  init_stage: "complete",
1152
1216
  checkpoint,
1153
1217
  batch_checkpoint: batchCheckpoint,
@@ -1176,10 +1240,11 @@ export async function commandInit(opts) {
1176
1240
  failure_signature: [],
1177
1241
  failure_streak: 0,
1178
1242
  last_error: null,
1179
- // Informational only — not a gate. Marks the freshly-imported script as not
1180
- // yet self-reviewed; the post-push review is tracked by the agent
1181
- // orchestration (script-reviewer subagent), not by a scriptctl command.
1182
1243
  review_status: "pending",
1244
+ review_missing: [...REVIEW_TARGETS],
1245
+ inspected_targets: [],
1246
+ patch_count: 0,
1247
+ exportable: providerName !== "mock",
1183
1248
  });
1184
1249
  const title = passed
1185
1250
  ? "INIT COMPLETE: Initial script ready"
@@ -1196,7 +1261,7 @@ export async function commandInit(opts) {
1196
1261
  `episode checkpoint reused: ${skipped.length}`,
1197
1262
  `batches: ${completedBatches}/${asList(batchPlan["batches"]).length} completed`,
1198
1263
  `batch checkpoint reused: ${skippedEpisodeBatchCount + skippedBatches.length}`,
1199
- "review: pending (post-push self-review)",
1264
+ "agent_review: pending",
1200
1265
  ],
1201
1266
  artifacts: [
1202
1267
  path.join(workspace, "source.txt"),
@@ -1214,10 +1279,10 @@ export async function commandInit(opts) {
1214
1279
  issues: summarizeIssues(asList(validation["issues"])),
1215
1280
  next: providerName === "mock"
1216
1281
  ? [
1217
- "Do not push mock-provider results for delivery.",
1218
- "Rerun init with --provider anthropic, then run scriptctl import push.",
1282
+ "Run inspect for issue, episode, and asset; apply patches if needed; then validate/export.",
1283
+ "Do not export mock-provider results for delivery.",
1219
1284
  ]
1220
- : ["Run scriptctl import push to publish the script to the DB."],
1285
+ : ["Run inspect for issue, episode, and asset; apply patches if needed; then validate/export."],
1221
1286
  };
1222
1287
  return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
1223
1288
  }
@@ -1233,4 +1298,580 @@ export function summarizeIssues(issues) {
1233
1298
  const first = issues[0];
1234
1299
  return [parts.join("; "), `first: ${first["code"]} - ${first["summary"]}`];
1235
1300
  }
1301
+ // ---------------------------------------------------------------------------
1302
+ // command_parse — subagent-authored four-document md → script.initial.json
1303
+ //
1304
+ // Deterministic, no-LLM counterpart of `direct init`: instead of extracting an
1305
+ // LLM-written spec md from raw source, it parses md that subagents already wrote
1306
+ // (per-episode 正文 md + one shared 资产.md), assembles the same
1307
+ // script.initial.json, and hands off to the existing direct
1308
+ // inspect/validate/export downstream (zero changes there).
1309
+ // ---------------------------------------------------------------------------
1310
+ const _EP_FILE_RE = /^ep[_-]?0*(\d+)\.(?:md|markdown)$/i;
1311
+ function resolveAssetsPath(opts, mdDir) {
1312
+ const explicit = strOf(opts["assets"]).trim();
1313
+ if (explicit) {
1314
+ if (!exists(explicit) || !fs.statSync(explicit).isFile()) {
1315
+ throw new CliError("PARSE BLOCKED: assets md not found", "assets md not found.", {
1316
+ exitCode: EXIT_INPUT,
1317
+ required: ["--assets: existing shared 资产 md file"],
1318
+ received: [`--assets: ${explicit}`],
1319
+ nextSteps: ["Point --assets at the shared 资产.md, or omit it to auto-detect in the md dir."],
1320
+ });
1321
+ }
1322
+ return explicit;
1323
+ }
1324
+ for (const name of ["资产.md", "assets.md", "资产.markdown"]) {
1325
+ const p = path.join(mdDir, name);
1326
+ if (exists(p))
1327
+ return p;
1328
+ }
1329
+ return null;
1330
+ }
1331
+ function collectEpisodeMdFiles(dir, assetsPath) {
1332
+ if (!exists(dir) || !fs.statSync(dir).isDirectory())
1333
+ return [];
1334
+ const assetsAbs = assetsPath ? path.resolve(assetsPath) : "";
1335
+ const out = [];
1336
+ for (const name of fs.readdirSync(dir)) {
1337
+ const m = _EP_FILE_RE.exec(name);
1338
+ if (!m)
1339
+ continue;
1340
+ const full = path.join(dir, name);
1341
+ if (path.resolve(full) === assetsAbs)
1342
+ continue;
1343
+ if (!fs.statSync(full).isFile())
1344
+ continue;
1345
+ out.push({ path: full, episode: parseInt(m[1], 10) });
1346
+ }
1347
+ out.sort((a, b) => a.episode - b.episode);
1348
+ return out;
1349
+ }
1350
+ export function commandParse(opts) {
1351
+ if (opts["spec"]) {
1352
+ return [{ title: "PARSE SPEC: 四文档 md 写法", body: PARSE_MD_SPEC }, EXIT_OK];
1353
+ }
1354
+ const workspace = strOf(opts["workspace_path"] || "workspace");
1355
+ const args = asList(opts["_args"]);
1356
+ const mdDir = strOf(opts["md_dir"] || args[0] || path.join(workspace, "parse"));
1357
+ if (!exists(mdDir) || !fs.statSync(mdDir).isDirectory()) {
1358
+ throw new CliError("PARSE BLOCKED: md workspace not found", "md workspace not found.", {
1359
+ exitCode: EXIT_INPUT,
1360
+ required: ["a directory with per-episode 正文 md + 资产.md"],
1361
+ received: [mdDir],
1362
+ nextSteps: ["Pass the md workspace dir: scriptctl parse <dir>. Run `scriptctl parse --spec` for the format."],
1363
+ });
1364
+ }
1365
+ const assetsPath = resolveAssetsPath(opts, mdDir);
1366
+ let episodesDir = strOf(opts["episodes_dir"]).trim();
1367
+ if (!episodesDir) {
1368
+ const sub = path.join(mdDir, "episodes");
1369
+ episodesDir = exists(sub) && fs.statSync(sub).isDirectory() ? sub : mdDir;
1370
+ }
1371
+ const bodyFiles = collectEpisodeMdFiles(episodesDir, assetsPath);
1372
+ if (bodyFiles.length === 0) {
1373
+ throw new CliError("PARSE BLOCKED: no episode md found", "no episode md found.", {
1374
+ exitCode: EXIT_INPUT,
1375
+ required: ["per-episode body md named like ep_001.md"],
1376
+ received: [episodesDir],
1377
+ nextSteps: ["Add per-episode 正文 md (ep_001.md, ep_002.md, ...). Run `scriptctl parse --spec` for the format."],
1378
+ });
1379
+ }
1380
+ let bible = null;
1381
+ if (assetsPath) {
1382
+ try {
1383
+ bible = parseMarkdownBatch(readText(assetsPath), { episode: 0, part: 1 }, { fragmentMode: true, allowEmptyScenes: true });
1384
+ }
1385
+ catch (exc) {
1386
+ const e = exc;
1387
+ throw new CliError("PARSE BLOCKED: assets md invalid", "assets md invalid.", {
1388
+ exitCode: EXIT_INPUT,
1389
+ required: ["shared 资产 md following `scriptctl parse --spec`"],
1390
+ received: [`${path.basename(assetsPath)}: ${(e?.message ?? "").slice(0, 200)}`],
1391
+ nextSteps: ["Fix the 资产 md and re-run parse."],
1392
+ });
1393
+ }
1394
+ }
1395
+ const results = [];
1396
+ const sourceChunks = [];
1397
+ for (const file of bodyFiles) {
1398
+ const bodyText = readText(file.path);
1399
+ sourceChunks.push(`# ep_${pad3(file.episode)}\n${bodyText.trim()}`);
1400
+ try {
1401
+ results.push(parseMarkdownBatch(bodyText, { episode: file.episode, part: 1 }, { fragmentMode: true }));
1402
+ }
1403
+ catch (exc) {
1404
+ const e = exc;
1405
+ throw new CliError("PARSE BLOCKED: episode md invalid", "episode md invalid.", {
1406
+ exitCode: EXIT_INPUT,
1407
+ required: ["per-episode 正文 md following `scriptctl parse --spec`"],
1408
+ received: [`${path.basename(file.path)}: ${(e?.message ?? "").slice(0, 200)}`],
1409
+ nextSteps: ["Fix the episode md and re-run parse."],
1410
+ });
1411
+ }
1412
+ }
1413
+ results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
1414
+ // Fold the shared asset declarations into the first episode result so their
1415
+ // descriptions / states flow into the merge. Asset names are deduplicated
1416
+ // globally by mergeEpisodeResults, so prepending bible-first gives the shared
1417
+ // (canonical) descriptions priority over anything inferred per episode.
1418
+ if (bible && results.length > 0) {
1419
+ const first = results[0];
1420
+ for (const key of ["actors", "locations", "props", "speakers", "state_definitions"]) {
1421
+ first[key] = [...asList(bible[key]), ...asList(first[key])];
1422
+ }
1423
+ }
1424
+ const title = strOf(opts["title"]).trim() || path.basename(path.resolve(mdDir));
1425
+ const script = mergeEpisodeResults(results, title);
1426
+ const globalSynopsis = bible ? strOf(bible["synopsis"]).trim() : "";
1427
+ if (globalSynopsis)
1428
+ script["synopsis"] = globalSynopsis;
1429
+ const dd = directDir(workspace);
1430
+ fs.mkdirSync(dd, { recursive: true });
1431
+ // Write source.txt so the existing direct validate/export downstream (which
1432
+ // gates on source.txt existing) works unchanged. For a parse-origin script the
1433
+ // authored md *is* the source, so we persist the concatenated bodies.
1434
+ fs.mkdirSync(workspace, { recursive: true });
1435
+ fs.writeFileSync(path.join(workspace, "source.txt"), sourceChunks.join("\n\n") + "\n", "utf-8");
1436
+ const scriptPath = path.join(dd, "script.initial.json");
1437
+ writeJson(scriptPath, script);
1438
+ const validation = validateScript(workspace, scriptPath, { requireSource: false });
1439
+ const passed = Boolean(validation["passed"]);
1440
+ updateRunState(workspace, {
1441
+ status: passed ? "ready_for_agent" : "needs_agent_repair",
1442
+ command: "parse",
1443
+ init_stage: "complete",
1444
+ provider: "parse",
1445
+ source_path: path.resolve(mdDir),
1446
+ script_path: scriptPath,
1447
+ validation_path: path.join(dd, "validation.json"),
1448
+ episode_total: results.length,
1449
+ episode_completed: results.length,
1450
+ review_status: "pending",
1451
+ review_missing: [...REVIEW_TARGETS],
1452
+ inspected_targets: [],
1453
+ patch_count: 0,
1454
+ exportable: true,
1455
+ last_error: null,
1456
+ });
1457
+ const stats = validation["stats"] ?? {};
1458
+ const report = {
1459
+ title: passed
1460
+ ? "PARSE COMPLETE: Initial script ready"
1461
+ : "PARSE NEEDS AGENT: Initial script written with repair issues",
1462
+ result: [
1463
+ `episodes: ${stats["episodes"] ?? results.length}`,
1464
+ `scenes: ${stats["scenes"] ?? 0}`,
1465
+ `actions: ${stats["actions"] ?? 0}`,
1466
+ `assets md: ${assetsPath ? path.basename(assetsPath) : "(none)"}`,
1467
+ `synopsis: ${globalSynopsis ? "yes" : "no"}`,
1468
+ `validation: ${passed ? "passed" : "needs repair"}`,
1469
+ "agent_review: pending",
1470
+ ],
1471
+ artifacts: [scriptPath, path.join(dd, "validation.json"), path.join(dd, "run_state.json")],
1472
+ issues: summarizeIssues(asList(validation["issues"])),
1473
+ next: ["Run direct inspect (episode/asset/issue) for the two-stage review; apply patches if needed; then direct validate/export."],
1474
+ };
1475
+ return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
1476
+ }
1477
+ // ---------------------------------------------------------------------------
1478
+ // command_validate
1479
+ // ---------------------------------------------------------------------------
1480
+ export function commandValidate(opts) {
1481
+ const workspace = strOf(opts["workspace_path"] || "workspace");
1482
+ const scriptPath = opts["script_path"] ? strOf(opts["script_path"]) : null;
1483
+ const validation = validateScript(workspace, scriptPath);
1484
+ const stats = validation["stats"] ?? {};
1485
+ const passed = Boolean(validation["passed"]);
1486
+ const report = {
1487
+ title: passed ? "VALIDATE PASSED: Script is ready" : "VALIDATE NEEDS AGENT: Repair issues found",
1488
+ result: [
1489
+ `episodes: ${stats["episodes"] ?? 0}`,
1490
+ `scenes: ${stats["scenes"] ?? 0}`,
1491
+ `actions: ${stats["actions"] ?? 0}`,
1492
+ ],
1493
+ artifacts: [path.join(directDir(workspace), "validation.json")],
1494
+ issues: summarizeIssues(asList(validation["issues"])),
1495
+ next: [passed ? "Export script.json." : "Inspect issues and apply structured patches."],
1496
+ };
1497
+ return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
1498
+ }
1499
+ // ---------------------------------------------------------------------------
1500
+ // command_inspect (incl. review rendering)
1501
+ // ---------------------------------------------------------------------------
1502
+ function renderReviewEpisode(sourceText, episodePlan, script, episodeNum) {
1503
+ const epId = fmtId("ep", episodeNum);
1504
+ const scriptEp = asList(script["episodes"]).find((ep) => ep["episode_id"] === epId);
1505
+ const planEp = asList(episodePlan["episodes"]).find((ep) => Number(ep["episode"] ?? 0) === episodeNum);
1506
+ const lines = [];
1507
+ if (!planEp && !scriptEp) {
1508
+ lines.push(`⚠ Episode ${episodeNum} not found in episode_plan.json or script.initial.json.`);
1509
+ const available = [...new Set(asList(episodePlan["episodes"]).map((ep) => Number(ep["episode"] ?? 0)))].sort((a, b) => a - b);
1510
+ if (available.length > 0)
1511
+ lines.push(`Available episodes: ${available.join(", ")}`);
1512
+ return lines;
1513
+ }
1514
+ const title = (scriptEp?.["title"] ?? planEp?.["title"]) || "(无标题)";
1515
+ lines.push("=".repeat(72));
1516
+ lines.push(`EPISODE ${episodeNum} / ${epId} — ${title}`);
1517
+ lines.push("=".repeat(72));
1518
+ lines.push("");
1519
+ lines.push("--- 原文 source.txt ---");
1520
+ if (planEp) {
1521
+ const span = isDict(planEp["source_span"]) ? planEp["source_span"] : {};
1522
+ const start = Number(span["start"] ?? 0);
1523
+ const end = Number(span["end"] ?? sourceText.length);
1524
+ const snippet = sourceText.slice(start, end).replace(/\s+$/, "");
1525
+ lines.push(snippet || "(empty)");
1526
+ }
1527
+ else {
1528
+ lines.push("(no episode_plan entry; source span unavailable)");
1529
+ }
1530
+ lines.push("");
1531
+ lines.push("--- 抽取 script.initial.json ---");
1532
+ if (scriptEp) {
1533
+ const actorIdToName = new Map();
1534
+ for (const a of asList(script["actors"]))
1535
+ actorIdToName.set(strOf(a["actor_id"]), strOf(a["actor_name"]));
1536
+ const speakerIdToName = new Map();
1537
+ for (const s of asList(script["speakers"]))
1538
+ speakerIdToName.set(strOf(s["speaker_id"]), strOf(s["display_name"]));
1539
+ const locationIdToName = new Map();
1540
+ for (const l of asList(script["locations"]))
1541
+ locationIdToName.set(strOf(l["location_id"]), strOf(l["location_name"]));
1542
+ const propIdToName = new Map();
1543
+ for (const p of asList(script["props"]))
1544
+ propIdToName.set(strOf(p["prop_id"]), strOf(p["prop_name"]));
1545
+ for (const scene of asList(scriptEp["scenes"])) {
1546
+ const env = isDict(scene["environment"]) ? scene["environment"] : {};
1547
+ const space = strOf(env["space"]) || "?";
1548
+ const timeOfDay = strOf(env["time"]) || "?";
1549
+ const sceneId = strOf(scene["scene_id"]) || "?";
1550
+ let locName = "";
1551
+ for (const ref of asList(scene["locations"])) {
1552
+ locName = locationIdToName.get(strOf(ref["location_id"])) || locName;
1553
+ break;
1554
+ }
1555
+ const actorNames = [];
1556
+ for (const ref of asList(scene["actors"])) {
1557
+ const n = actorIdToName.get(strOf(ref["actor_id"]));
1558
+ if (n)
1559
+ actorNames.push(n);
1560
+ }
1561
+ const propNames = [];
1562
+ for (const ref of asList(scene["props"])) {
1563
+ const n = propIdToName.get(strOf(ref["prop_id"]));
1564
+ if (n)
1565
+ propNames.push(n);
1566
+ }
1567
+ lines.push("");
1568
+ lines.push(`Scene ${sceneId} [${space} ${timeOfDay}] ${locName || "(未知场景)"}`);
1569
+ if (actorNames.length > 0)
1570
+ lines.push(` Actors: ${actorNames.join(", ")}`);
1571
+ if (propNames.length > 0)
1572
+ lines.push(` Props: ${propNames.join(", ")}`);
1573
+ for (const action of asList(scene["actions"])) {
1574
+ const kind = strOf(action["type"]);
1575
+ const content = strOf(action["content"]).replace(/\n/g, "\n ");
1576
+ let tag;
1577
+ if (kind === "dialogue") {
1578
+ const speaker = actorIdToName.get(strOf(action["actor_id"])) || speakerIdToName.get(strOf(action["speaker_id"])) || strOf(action["speaker"]) || "?";
1579
+ const emotion = strOf(action["emotion"]);
1580
+ tag = `dlg|${speaker}` + (emotion ? `|${emotion}` : "");
1581
+ }
1582
+ else if (kind === "inner_thought") {
1583
+ const speaker = actorIdToName.get(strOf(action["actor_id"])) || speakerIdToName.get(strOf(action["speaker_id"])) || strOf(action["speaker"]) || "?";
1584
+ const emotion = strOf(action["emotion"]);
1585
+ tag = `think|${speaker}` + (emotion ? `|${emotion}` : "");
1586
+ }
1587
+ else {
1588
+ tag = "act";
1589
+ }
1590
+ lines.push(` [${tag}] ${content}`);
1591
+ }
1592
+ }
1593
+ }
1594
+ else {
1595
+ lines.push("(script.initial.json contains no entry for this episode)");
1596
+ }
1597
+ if (scriptEp) {
1598
+ const actorIdsInEp = new Set();
1599
+ const locationIdsInEp = new Set();
1600
+ const propIdsInEp = new Set();
1601
+ for (const scene of asList(scriptEp["scenes"])) {
1602
+ for (const ref of asList(scene["actors"])) {
1603
+ if (ref["actor_id"])
1604
+ actorIdsInEp.add(strOf(ref["actor_id"]));
1605
+ }
1606
+ for (const ref of asList(scene["locations"])) {
1607
+ if (ref["location_id"])
1608
+ locationIdsInEp.add(strOf(ref["location_id"]));
1609
+ }
1610
+ for (const ref of asList(scene["props"])) {
1611
+ if (ref["prop_id"])
1612
+ propIdsInEp.add(strOf(ref["prop_id"]));
1613
+ }
1614
+ for (const action of asList(scene["actions"])) {
1615
+ if (action["actor_id"])
1616
+ actorIdsInEp.add(strOf(action["actor_id"]));
1617
+ }
1618
+ }
1619
+ if (actorIdsInEp.size > 0 || locationIdsInEp.size > 0 || propIdsInEp.size > 0) {
1620
+ lines.push("");
1621
+ lines.push(`--- Episode ${episodeNum} 资产 ---`);
1622
+ }
1623
+ if (actorIdsInEp.size > 0) {
1624
+ lines.push("Actors:");
1625
+ for (const actor of asList(script["actors"])) {
1626
+ if (!actorIdsInEp.has(strOf(actor["actor_id"])))
1627
+ continue;
1628
+ const desc = strOf(actor["description"]).trim() || "(无描述)";
1629
+ const role = strOf(actor["role_type"]) || "?";
1630
+ lines.push(` - ${actor["actor_id"]} ${actor["actor_name"]} [${role}] — ${desc}`);
1631
+ }
1632
+ }
1633
+ if (locationIdsInEp.size > 0) {
1634
+ lines.push("Locations:");
1635
+ for (const loc of asList(script["locations"])) {
1636
+ if (!locationIdsInEp.has(strOf(loc["location_id"])))
1637
+ continue;
1638
+ const desc = strOf(loc["description"]).trim() || "(无描述)";
1639
+ lines.push(` - ${loc["location_id"]} ${loc["location_name"]} — ${desc}`);
1640
+ }
1641
+ }
1642
+ if (propIdsInEp.size > 0) {
1643
+ lines.push("Props:");
1644
+ for (const prop of asList(script["props"])) {
1645
+ if (!propIdsInEp.has(strOf(prop["prop_id"])))
1646
+ continue;
1647
+ const desc = strOf(prop["description"]).trim() || "(无描述)";
1648
+ lines.push(` - ${prop["prop_id"]} ${prop["prop_name"]} — ${desc}`);
1649
+ }
1650
+ }
1651
+ }
1652
+ return lines;
1653
+ }
1654
+ function renderAssetCurationSummary(curation) {
1655
+ const summary = isDict(curation["summary"]) ? curation["summary"] : {};
1656
+ const lines = [
1657
+ `curation: ` +
1658
+ `actors ${summary["actors_before"] ?? 0}->${summary["actors_after"] ?? 0} ` +
1659
+ `(removed ${summary["actors_removed"] ?? 0}), ` +
1660
+ `props ${summary["props_before"] ?? 0}->${summary["props_after"] ?? 0} ` +
1661
+ `(removed ${summary["props_removed"] ?? 0}), ` +
1662
+ `locations ${summary["locations_before"] ?? 0}->${summary["locations_after"] ?? 0} ` +
1663
+ `(merged ${summary["locations_merged"] ?? 0})`,
1664
+ ];
1665
+ for (const actor of asList(curation["actors"])) {
1666
+ if (!isDict(actor) || actor["decision"] !== "remove")
1667
+ continue;
1668
+ lines.push(`curation actor ${actor["actor_id"]}: remove ${actor["name"] || "-"} (scenes=${actor["scene_count"] ?? 0}) — ${actor["reason"] || "-"}`);
1669
+ }
1670
+ for (const prop of asList(curation["props"])) {
1671
+ if (!isDict(prop) || prop["decision"] !== "remove")
1672
+ continue;
1673
+ lines.push(`curation prop ${prop["prop_id"]}: remove ${prop["name"] || "-"} (scenes=${prop["scene_count"] ?? 0}) — ${prop["reason"] || "-"}`);
1674
+ }
1675
+ for (const loc of asList(curation["locations"])) {
1676
+ if (!isDict(loc) || loc["decision"] !== "merge")
1677
+ continue;
1678
+ lines.push(`curation location ${loc["location_id"]}: merge ${loc["name"] || "-"} -> ${loc["target_location_id"] || "-"} — ${loc["reason"] || "-"}`);
1679
+ }
1680
+ return lines;
1681
+ }
1682
+ export function commandInspect(opts) {
1683
+ const workspace = strOf(opts["workspace_path"] || "workspace");
1684
+ const target = strOf(opts["target"]);
1685
+ const itemId = opts["id"] ? strOf(opts["id"]) : null;
1686
+ const dd = directDir(workspace);
1687
+ const scriptPath = path.join(dd, "script.initial.json");
1688
+ const validationPath = path.join(dd, "validation.json");
1689
+ if (!exists(scriptPath) && target === "asset") {
1690
+ throw new CliError("INSPECT BLOCKED: script.initial.json not found", "script.initial.json not found.", {
1691
+ exitCode: EXIT_INPUT,
1692
+ required: ["workspace/draft/scriptctl/direct/script.initial.json"],
1693
+ received: [scriptPath],
1694
+ nextSteps: ["Run scriptctl direct init first."],
1695
+ });
1696
+ }
1697
+ const script = exists(scriptPath)
1698
+ ? readJson(scriptPath)
1699
+ : { episodes: [], actors: [], locations: [], props: [] };
1700
+ let validation = exists(validationPath) ? readJson(validationPath) : { issues: [] };
1701
+ if (target === "issue" && exists(scriptPath)) {
1702
+ validation = validateScript(workspace, scriptPath, { requireSource: false });
1703
+ }
1704
+ const batchPlanPath = path.join(dd, "batch_plan.json");
1705
+ const batchPlan = exists(batchPlanPath) ? readJson(batchPlanPath) : { batches: [] };
1706
+ const batchCounts = new Map();
1707
+ for (const batch of asList(batchPlan["batches"])) {
1708
+ if (!isDict(batch))
1709
+ continue;
1710
+ const ep = Number(batch["episode"] ?? 0);
1711
+ batchCounts.set(ep, (batchCounts.get(ep) ?? 0) + 1);
1712
+ }
1713
+ const failedByEpisode = new Map();
1714
+ const batchResultsDir = path.join(dd, "batch_results");
1715
+ if (exists(batchResultsDir)) {
1716
+ const files = fs.readdirSync(batchResultsDir).filter((n) => n.endsWith(".error.json")).sort();
1717
+ for (const name of files) {
1718
+ const errorFile = path.join(batchResultsDir, name);
1719
+ try {
1720
+ const error = readJson(errorFile);
1721
+ const episodeNum = Number(error["episode"] ?? 0);
1722
+ if (!failedByEpisode.has(episodeNum))
1723
+ failedByEpisode.set(episodeNum, []);
1724
+ failedByEpisode.get(episodeNum).push(strOf(error["batch_id"]) || name.replace(/\.error\.json$/, ""));
1725
+ }
1726
+ catch {
1727
+ // ignore
1728
+ }
1729
+ }
1730
+ }
1731
+ const lines = [];
1732
+ if (target === "episode") {
1733
+ if (exists(scriptPath)) {
1734
+ for (const ep of asList(script["episodes"])) {
1735
+ if (itemId && itemId !== strOf(ep["episode_id"]))
1736
+ continue;
1737
+ const scenes = asList(ep["scenes"]);
1738
+ const sceneCount = scenes.length;
1739
+ let actionCount = 0;
1740
+ for (const scene of scenes)
1741
+ actionCount += asList(scene["actions"]).length;
1742
+ const rawNum = normalizeInt(strOf(ep["episode_id"]), lines.length + 1);
1743
+ const failed = (failedByEpisode.get(rawNum) ?? []).join(",") || "-";
1744
+ lines.push(`${ep["episode_id"]}: scenes=${sceneCount}, actions=${actionCount}, batches=${batchCounts.get(rawNum) ?? 0}, failed_batches=${failed}, title=${ep["title"] || "-"}`);
1745
+ }
1746
+ }
1747
+ else {
1748
+ const planPath = path.join(dd, "episode_plan.json");
1749
+ const plan = exists(planPath) ? readJson(planPath) : { episodes: [] };
1750
+ for (const ep of asList(plan["episodes"])) {
1751
+ const epId = fmtId("ep", Number(ep["episode"] ?? 0));
1752
+ if (itemId && itemId !== epId && itemId !== strOf(ep["episode"]))
1753
+ continue;
1754
+ const episodeNum = Number(ep["episode"] ?? 0);
1755
+ const failed = (failedByEpisode.get(episodeNum) ?? []).join(",") || "-";
1756
+ lines.push(`${epId}: batches=${batchCounts.get(episodeNum) ?? 0}, failed_batches=${failed}, title=${ep["title"] || "-"}`);
1757
+ }
1758
+ }
1759
+ }
1760
+ else if (target === "asset") {
1761
+ const curationPath = path.join(dd, "asset_curation.json");
1762
+ if (!itemId && exists(curationPath)) {
1763
+ const curation = readJson(curationPath);
1764
+ if (isDict(curation))
1765
+ lines.push(...renderAssetCurationSummary(curation));
1766
+ }
1767
+ for (const [key, idKey, nameKey] of [["actors", "actor_id", "actor_name"], ["locations", "location_id", "location_name"], ["props", "prop_id", "prop_name"]]) {
1768
+ for (const asset of asList(script[key])) {
1769
+ if (itemId && itemId !== strOf(asset[idKey]) && itemId !== strOf(asset[nameKey]))
1770
+ continue;
1771
+ const aliases = asList(asset["aliases"]).length;
1772
+ const states = asList(asset["states"]).length;
1773
+ const role = key === "actors" ? `, role_type=${asset["role_type"]}` : "";
1774
+ const description = strOf(asset["description"]).trim() ? "yes" : "missing";
1775
+ const singular = key.slice(0, -1);
1776
+ lines.push(`${singular} ${asset[idKey]}: ${asset[nameKey]} aliases=${aliases}, states=${states}${role}, description=${description}`);
1777
+ }
1778
+ }
1779
+ }
1780
+ else if (target === "issue") {
1781
+ for (const errors of failedByEpisode.values()) {
1782
+ for (const batchId of errors) {
1783
+ const errorPath = path.join(batchResultsDir, `${batchId}.error.json`);
1784
+ if (!exists(errorPath))
1785
+ continue;
1786
+ let error;
1787
+ try {
1788
+ error = readJson(errorPath);
1789
+ }
1790
+ catch {
1791
+ continue;
1792
+ }
1793
+ if (itemId && itemId !== strOf(error["batch_id"]) && itemId !== strOf(error["episode"]) && itemId !== strOf(error["error_type"]))
1794
+ continue;
1795
+ lines.push(`error BATCH_FAILED: ${error["batch_id"]} episode ${error["episode"]} part ${error["part"]} - ${error["message"]}`);
1796
+ }
1797
+ }
1798
+ for (const issue of asList(validation["issues"])) {
1799
+ if (itemId && itemId !== strOf(issue["code"]) && itemId !== strOf(issue["severity"]))
1800
+ continue;
1801
+ const whereParts = [];
1802
+ for (const k of ["episode", "scene", "action_index"]) {
1803
+ if (issue[k] !== null && issue[k] !== undefined)
1804
+ whereParts.push(strOf(issue[k]));
1805
+ }
1806
+ const where = whereParts.join(" ");
1807
+ lines.push(`${issue["severity"]} ${issue["code"]}: ${issue["summary"]}${where ? ` [${where}]` : ""}`);
1808
+ }
1809
+ }
1810
+ else if (target === "review") {
1811
+ const episodeArg = strOf(opts["episode"]).trim();
1812
+ if (!episodeArg) {
1813
+ throw new CliError("INSPECT BLOCKED: --episode required for review", "--episode required for review.", {
1814
+ exitCode: EXIT_USAGE,
1815
+ required: ["--episode <n>[,<n>...]"],
1816
+ received: ["--episode not provided"],
1817
+ nextSteps: ["Pass --episode 1 (single) or --episode 1,15,30 (multiple)."],
1818
+ });
1819
+ }
1820
+ let episodeFilters;
1821
+ try {
1822
+ episodeFilters = episodeArg.split(",").map((s) => s.trim()).filter((s) => s).map((s) => {
1823
+ const n = parseInt(s, 10);
1824
+ if (Number.isNaN(n))
1825
+ throw new Error("nan");
1826
+ return n;
1827
+ });
1828
+ }
1829
+ catch {
1830
+ throw new CliError("INSPECT BLOCKED: --episode must be integers", "--episode must be integers.", {
1831
+ exitCode: EXIT_USAGE,
1832
+ required: ["--episode <n>[,<n>...]"],
1833
+ received: [`--episode ${episodeArg}`],
1834
+ nextSteps: ["Pass episode numbers like --episode 1,15,30."],
1835
+ });
1836
+ }
1837
+ const sourcePath = path.join(workspace, "source.txt");
1838
+ if (!exists(sourcePath)) {
1839
+ throw new CliError("INSPECT BLOCKED: source.txt not found", "source.txt not found.", {
1840
+ exitCode: EXIT_INPUT,
1841
+ required: [sourcePath],
1842
+ received: ["source.txt missing"],
1843
+ nextSteps: ["Run scriptctl direct init first."],
1844
+ });
1845
+ }
1846
+ const sourceText = readText(sourcePath);
1847
+ const episodePlanPath = path.join(dd, "episode_plan.json");
1848
+ const episodePlan = exists(episodePlanPath) ? readJson(episodePlanPath) : { episodes: [] };
1849
+ for (let idx = 0; idx < episodeFilters.length; idx++) {
1850
+ if (idx > 0) {
1851
+ lines.push("");
1852
+ lines.push("");
1853
+ }
1854
+ lines.push(...renderReviewEpisode(sourceText, episodePlan, script, episodeFilters[idx]));
1855
+ }
1856
+ }
1857
+ else {
1858
+ throw new CliError("INSPECT BLOCKED: Invalid target", "Invalid inspect target.", {
1859
+ exitCode: EXIT_USAGE,
1860
+ required: ["--target: episode, asset, issue, or review"],
1861
+ received: [`--target: ${target}`],
1862
+ nextSteps: ["Use one of the supported inspect targets."],
1863
+ });
1864
+ }
1865
+ const state = addInspectedTarget(workspace, target);
1866
+ const missingReview = reviewBlockers(state);
1867
+ const report = {
1868
+ title: `INSPECT: ${target}`,
1869
+ result: lines.length > 0 ? lines : ["No matching items."],
1870
+ next: [
1871
+ "Use inspect details to prepare a structured patch, or continue required review.",
1872
+ missingReview.length === 0 ? "Review complete; run validate/export." : `Still inspect: ${missingReview.join(", ")}.`,
1873
+ ],
1874
+ };
1875
+ return [report, EXIT_OK];
1876
+ }
1236
1877
  //# sourceMappingURL=direct.js.map