@lingjingai/scriptctl 0.12.1 → 0.13.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, EXIT_INPUT, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, REVIEW_TARGETS, SUPPORTED_EXTS, deletePath, directDir, exists, fmtId, readJson, readText, writeJson, } from "../common.js";
4
- import { compactBatchResult, compactEpisodeResult, buildBatchPlan, buildEpisodePlan, DEFAULT_TITLE_CHUNK_SIZE, chunkEpisodesNeedingTitles, applyEpisodeTitlesToPlan, episodeTitleResponseMap, extractBatchWithRecovery, mergeEpisodeResults, normalizeEpisodeResult, normalizeInt, uniqueAdd, 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, EXIT_INPUT, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, SUPPORTED_EXTS, deletePath, directDir, exists, fmtId, readJson, readText, writeJson, } from "../common.js";
4
+ import { compactBatchResult, compactEpisodeResult, buildBatchPlan, buildEpisodePlan, DEFAULT_TITLE_CHUNK_SIZE, chunkEpisodesNeedingTitles, applyEpisodeTitlesToPlan, episodeTitleResponseMap, extractBatchWithRecovery, mergeEpisodeResults, normalizeEpisodeResult, uniqueAdd, 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";
@@ -60,69 +60,6 @@ export function readRunState(workspace) {
60
60
  return {};
61
61
  }
62
62
  }
63
- export function addInspectedTarget(workspace, target) {
64
- const state = readRunState(workspace);
65
- const targets = [];
66
- for (const item of asList(state["inspected_targets"])) {
67
- const s = strOf(item);
68
- if (s)
69
- targets.push(s);
70
- }
71
- if (!targets.includes(target))
72
- targets.push(target);
73
- const missing = [];
74
- for (const t of REVIEW_TARGETS) {
75
- if (!targets.includes(t))
76
- missing.push(t);
77
- }
78
- const reviewStatus = missing.length === 0 ? "reviewed" : "in_progress";
79
- return updateRunState(workspace, {
80
- inspected_targets: targets,
81
- review_status: reviewStatus,
82
- review_missing: missing,
83
- last_inspect_target: target,
84
- });
85
- }
86
- export function markPatched(workspace, count) {
87
- const state = readRunState(workspace);
88
- const patchCount = Number(state["patch_count"] ?? 0) + count;
89
- return updateRunState(workspace, {
90
- patch_count: patchCount,
91
- review_status: "patched",
92
- review_missing: [],
93
- });
94
- }
95
- export function markMetadataConfidenceReviewed(workspace, operations) {
96
- const metadataOps = new Set(["meta.worldview.set", "asset.role.set", "asset.describe"]);
97
- if (!operations.some((op) => isDict(op) && metadataOps.has(strOf(op["op"]))))
98
- return;
99
- const p = path.join(directDir(workspace), "asset_metadata.json");
100
- if (!exists(p))
101
- return;
102
- let metadata;
103
- try {
104
- metadata = readJson(p);
105
- }
106
- catch {
107
- return;
108
- }
109
- if (!isDict(metadata) || metadata["confidence"] !== "low")
110
- return;
111
- metadata["confidence"] = "medium";
112
- metadata["confidence_reviewed_at"] = checkpointTimestamp();
113
- writeJson(p, metadata);
114
- }
115
- export function reviewBlockers(state) {
116
- if (Number(state["patch_count"] ?? 0) > 0)
117
- return [];
118
- const inspected = new Set();
119
- for (const item of asList(state["inspected_targets"])) {
120
- const s = strOf(item);
121
- if (s)
122
- inspected.add(s);
123
- }
124
- return REVIEW_TARGETS.filter((t) => !inspected.has(t));
125
- }
126
63
  // ---------------------------------------------------------------------------
127
64
  // Paths for episode/batch results
128
65
  // ---------------------------------------------------------------------------
@@ -324,7 +261,7 @@ async function providerExtractAssetCurationLocal(provider, sourceText, script) {
324
261
  // Record a batch failure: write `<key>.error.json` (which marks the batch as
325
262
  // "tried and failed" → sticky on plain resume) and drop any stale result file.
326
263
  // The error_code (e.g. PROVIDER_CONTENT_FILTERED) is surfaced so the agent can
327
- // see WHY in `direct status` / `direct inspect` and decide whether to re-run.
264
+ // see WHY in `direct status` / `direct review` and decide whether to re-run.
328
265
  //
329
266
  // Deliberate content-filter policy split: batch extraction is HARD (sticky
330
267
  // error, episode blocked until an explicit re-run) because batch content is the
@@ -715,23 +652,28 @@ export async function commandInit(opts) {
715
652
  episode_total: totalEpisodes,
716
653
  batch_total: totalBatches,
717
654
  });
718
- const failures = [];
719
- const outcomes = await pMapWithConcurrency(pending, concurrency, async (batch) => extractBatchWithRecovery(provider, sourceText, batch));
720
- for (let i = 0; i < outcomes.length; i++) {
721
- const outcome = outcomes[i];
722
- const batch = pending[i];
723
- if (outcome.ok) {
724
- persistBatchResult(batchResultsDir, batch, outcome.value);
655
+ // Persist each batch the moment it finishes (inside the worker), NOT after the
656
+ // whole pool drains. Per-unit files have no shared index, so concurrent writes
657
+ // are safe and batch_results/ fills up live, so a crash/kill mid-run keeps
658
+ // every batch already extracted (the resume model depends on this).
659
+ const outcomes = await pMapWithConcurrency(pending, concurrency, async (batch) => {
660
+ try {
661
+ const result = await extractBatchWithRecovery(provider, sourceText, batch);
662
+ persistBatchResult(batchResultsDir, batch, result);
725
663
  const errorPath = batchErrorPath(batchResultsDir, batch);
726
664
  if (exists(errorPath))
727
665
  deletePath(errorPath);
666
+ return true;
728
667
  }
729
- else {
730
- failures.push(writeBatchFailure(batchResultsDir, batch, outcome.error));
668
+ catch (error) {
669
+ writeBatchFailure(batchResultsDir, batch, error);
670
+ return false;
731
671
  }
732
- }
733
- const doneBatches = reusedBatchCount + outcomes.filter((o) => o.ok).length;
734
- const erroredBatches = stickyErrorCount + failures.length;
672
+ });
673
+ const freshOk = outcomes.filter((o) => o.ok && o.value === true).length;
674
+ const freshFail = outcomes.filter((o) => o.ok && o.value === false).length;
675
+ const doneBatches = reusedBatchCount + freshOk;
676
+ const erroredBatches = stickyErrorCount + freshFail;
735
677
  // Assemble every episode whose batches are all present on disk (reused +
736
678
  // freshly extracted, read uniformly from disk). Incomplete episodes are
737
679
  // reported; assembling them is left to an explicit re-run.
@@ -936,10 +878,6 @@ export async function commandInit(opts) {
936
878
  batch_reused: reusedBatchCount,
937
879
  batch_failed: erroredBatches,
938
880
  last_error: null,
939
- review_status: "pending",
940
- review_missing: [...REVIEW_TARGETS],
941
- inspected_targets: [],
942
- patch_count: 0,
943
881
  });
944
882
  const title = passed
945
883
  ? "INIT COMPLETE: Initial script ready"
@@ -956,7 +894,6 @@ export async function commandInit(opts) {
956
894
  `episodes: ${results.length}/${totalEpisodes} complete`,
957
895
  `batches: ${doneBatches}/${totalBatches} done (reused ${reusedBatchCount})`,
958
896
  ...(titleDegraded.length > 0 ? [`titles degraded (deterministic): ${titleDegraded.join(", ")}`] : []),
959
- "agent_review: pending",
960
897
  ],
961
898
  artifacts: [
962
899
  path.join(workspace, "source.txt"),
@@ -974,10 +911,10 @@ export async function commandInit(opts) {
974
911
  issues: summarizeIssues(asList(validation["issues"])),
975
912
  next: providerName === "mock"
976
913
  ? [
977
- "Run inspect for issue, episode, and asset; apply patches if needed; then validate/export.",
914
+ "Run `direct review` (add --episode <n> for source↔extract); edit with --draft if needed; then validate/export.",
978
915
  "Do not export mock-provider results for delivery.",
979
916
  ]
980
- : ["Run inspect for issue, episode, and asset; apply patches if needed; then validate/export."],
917
+ : ["Run `direct review` (add --episode <n> for source↔extract); edit with --draft if needed; then validate/export."],
981
918
  };
982
919
  return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
983
920
  }
@@ -1299,173 +1236,81 @@ function renderAssetCurationSummary(curation) {
1299
1236
  }
1300
1237
  return lines;
1301
1238
  }
1302
- export function commandInspect(opts) {
1303
- const workspace = strOf(opts["workspace_path"] || "workspace");
1304
- const target = strOf(opts["target"]);
1305
- const itemId = opts["id"] ? strOf(opts["id"]) : null;
1306
- const dd = directDir(workspace);
1307
- const scriptPath = path.join(dd, "script.initial.json");
1308
- const validationPath = path.join(dd, "validation.json");
1309
- if (!exists(scriptPath) && target === "asset") {
1310
- throw new CliError("INSPECT BLOCKED: script.initial.json not found", "script.initial.json not found.", {
1311
- exitCode: EXIT_INPUT,
1312
- required: ["workspace/draft/scriptctl/direct/script.initial.json"],
1313
- received: [scriptPath],
1314
- nextSteps: ["Run scriptctl direct init first."],
1315
- });
1316
- }
1317
- const script = exists(scriptPath)
1318
- ? readJson(scriptPath)
1319
- : { episodes: [], actors: [], locations: [], props: [] };
1320
- let validation = exists(validationPath) ? readJson(validationPath) : { issues: [] };
1321
- if (target === "issue" && exists(scriptPath)) {
1322
- validation = validateScript(workspace, scriptPath, { requireSource: false });
1323
- }
1324
- const batchPlanPath = path.join(dd, "batch_plan.json");
1325
- const batchPlan = exists(batchPlanPath) ? readJson(batchPlanPath) : { batches: [] };
1326
- const batchCounts = new Map();
1327
- for (const batch of asList(batchPlan["batches"])) {
1328
- if (!isDict(batch))
1329
- continue;
1330
- const ep = Number(batch["episode"] ?? 0);
1331
- batchCounts.set(ep, (batchCounts.get(ep) ?? 0) + 1);
1332
- }
1333
- const failedByEpisode = new Map();
1334
- const batchResultsDir = path.join(dd, "batch_results");
1335
- if (exists(batchResultsDir)) {
1336
- const files = fs.readdirSync(batchResultsDir).filter((n) => n.endsWith(".error.json")).sort();
1337
- for (const name of files) {
1338
- const errorFile = path.join(batchResultsDir, name);
1339
- try {
1340
- const error = readJson(errorFile);
1341
- const episodeNum = Number(error["episode"] ?? 0);
1342
- if (!failedByEpisode.has(episodeNum))
1343
- failedByEpisode.set(episodeNum, []);
1344
- failedByEpisode.get(episodeNum).push(strOf(error["batch_id"]) || name.replace(/\.error\.json$/, ""));
1345
- }
1346
- catch {
1347
- // ignore
1348
- }
1349
- }
1350
- }
1351
- const lines = [];
1352
- if (target === "episode") {
1353
- if (exists(scriptPath)) {
1354
- for (const ep of asList(script["episodes"])) {
1355
- if (itemId && itemId !== strOf(ep["episode_id"]))
1356
- continue;
1357
- const scenes = asList(ep["scenes"]);
1358
- const sceneCount = scenes.length;
1359
- let actionCount = 0;
1360
- for (const scene of scenes)
1361
- actionCount += asList(scene["actions"]).length;
1362
- const rawNum = normalizeInt(strOf(ep["episode_id"]), lines.length + 1);
1363
- const failed = (failedByEpisode.get(rawNum) ?? []).join(",") || "-";
1364
- lines.push(`${ep["episode_id"]}: scenes=${sceneCount}, actions=${actionCount}, batches=${batchCounts.get(rawNum) ?? 0}, failed_batches=${failed}, title=${ep["title"] || "-"}`);
1365
- }
1239
+ // Collect batch extraction failures (batch_results/<key>.error.json) keyed by
1240
+ // episode. Shared by both review views — these are direct-stage failures the
1241
+ // final-script `issues` query can't see.
1242
+ function collectBatchFailures(batchResultsDir) {
1243
+ const byEpisode = new Map();
1244
+ if (!exists(batchResultsDir))
1245
+ return byEpisode;
1246
+ const files = fs.readdirSync(batchResultsDir).filter((n) => n.endsWith(".error.json")).sort();
1247
+ for (const name of files) {
1248
+ try {
1249
+ const error = readJson(path.join(batchResultsDir, name));
1250
+ const episodeNum = Number(error["episode"] ?? 0);
1251
+ if (!byEpisode.has(episodeNum))
1252
+ byEpisode.set(episodeNum, []);
1253
+ byEpisode.get(episodeNum).push(error);
1366
1254
  }
1367
- else {
1368
- const planPath = path.join(dd, "episode_plan.json");
1369
- const plan = exists(planPath) ? readJson(planPath) : { episodes: [] };
1370
- for (const ep of asList(plan["episodes"])) {
1371
- const epId = fmtId("ep", Number(ep["episode"] ?? 0));
1372
- if (itemId && itemId !== epId && itemId !== strOf(ep["episode"]))
1373
- continue;
1374
- const episodeNum = Number(ep["episode"] ?? 0);
1375
- const failed = (failedByEpisode.get(episodeNum) ?? []).join(",") || "-";
1376
- lines.push(`${epId}: batches=${batchCounts.get(episodeNum) ?? 0}, failed_batches=${failed}, title=${ep["title"] || "-"}`);
1377
- }
1255
+ catch {
1256
+ // ignore unreadable error file
1378
1257
  }
1379
1258
  }
1380
- else if (target === "asset") {
1381
- const curationPath = path.join(dd, "asset_curation.json");
1382
- if (!itemId && exists(curationPath)) {
1383
- const curation = readJson(curationPath);
1384
- if (isDict(curation))
1385
- lines.push(...renderAssetCurationSummary(curation));
1386
- }
1387
- for (const [key, idKey, nameKey] of [["actors", "actor_id", "actor_name"], ["locations", "location_id", "location_name"], ["props", "prop_id", "prop_name"]]) {
1388
- for (const asset of asList(script[key])) {
1389
- if (itemId && itemId !== strOf(asset[idKey]) && itemId !== strOf(asset[nameKey]))
1390
- continue;
1391
- const aliases = asList(asset["aliases"]).length;
1392
- const states = asList(asset["states"]).length;
1393
- const role = key === "actors" ? `, role_type=${asset["role_type"]}` : "";
1394
- const description = strOf(asset["description"]).trim() ? "yes" : "missing";
1395
- const singular = key.slice(0, -1);
1396
- lines.push(`${singular} ${asset[idKey]}: ${asset[nameKey]} aliases=${aliases}, states=${states}${role}, description=${description}`);
1397
- }
1398
- }
1259
+ return byEpisode;
1260
+ }
1261
+ function parseEpisodeArg(episodeArg) {
1262
+ try {
1263
+ return episodeArg.split(",").map((s) => s.trim()).filter((s) => s).map((s) => {
1264
+ const n = parseInt(s, 10);
1265
+ if (Number.isNaN(n))
1266
+ throw new Error("nan");
1267
+ return n;
1268
+ });
1399
1269
  }
1400
- else if (target === "issue") {
1401
- for (const errors of failedByEpisode.values()) {
1402
- for (const batchId of errors) {
1403
- const errorPath = path.join(batchResultsDir, `${batchId}.error.json`);
1404
- if (!exists(errorPath))
1405
- continue;
1406
- let error;
1407
- try {
1408
- error = readJson(errorPath);
1409
- }
1410
- catch {
1411
- continue;
1412
- }
1413
- if (itemId && itemId !== strOf(error["batch_id"]) && itemId !== strOf(error["episode"]) && itemId !== strOf(error["error_type"]))
1414
- continue;
1415
- lines.push(`error BATCH_FAILED: ${error["batch_id"]} episode ${error["episode"]} part ${error["part"]} - ${error["message"]}`);
1416
- }
1417
- }
1418
- for (const issue of asList(validation["issues"])) {
1419
- if (itemId && itemId !== strOf(issue["code"]) && itemId !== strOf(issue["severity"]))
1420
- continue;
1421
- const whereParts = [];
1422
- for (const k of ["episode", "scene", "action_index"]) {
1423
- if (issue[k] !== null && issue[k] !== undefined)
1424
- whereParts.push(strOf(issue[k]));
1425
- }
1426
- const where = whereParts.join(" ");
1427
- lines.push(`${issue["severity"]} ${issue["code"]}: ${issue["summary"]}${where ? ` [${where}]` : ""}`);
1428
- }
1270
+ catch {
1271
+ throw new CliError("REVIEW BLOCKED: --episode must be integers", "--episode must be integers.", {
1272
+ exitCode: EXIT_USAGE,
1273
+ required: ["--episode <n>[,<n>...]"],
1274
+ received: [`--episode ${episodeArg}`],
1275
+ nextSteps: ["Pass episode numbers like --episode 1,15,30."],
1276
+ });
1429
1277
  }
1430
- else if (target === "review") {
1431
- const episodeArg = strOf(opts["episode"]).trim();
1432
- if (!episodeArg) {
1433
- throw new CliError("INSPECT BLOCKED: --episode required for review", "--episode required for review.", {
1434
- exitCode: EXIT_USAGE,
1435
- required: ["--episode <n>[,<n>...]"],
1436
- received: ["--episode not provided"],
1437
- nextSteps: ["Pass --episode 1 (single) or --episode 1,15,30 (multiple)."],
1438
- });
1439
- }
1440
- let episodeFilters;
1441
- try {
1442
- episodeFilters = episodeArg.split(",").map((s) => s.trim()).filter((s) => s).map((s) => {
1443
- const n = parseInt(s, 10);
1444
- if (Number.isNaN(n))
1445
- throw new Error("nan");
1446
- return n;
1447
- });
1448
- }
1449
- catch {
1450
- throw new CliError("INSPECT BLOCKED: --episode must be integers", "--episode must be integers.", {
1451
- exitCode: EXIT_USAGE,
1452
- required: ["--episode <n>[,<n>...]"],
1453
- received: [`--episode ${episodeArg}`],
1454
- nextSteps: ["Pass episode numbers like --episode 1,15,30."],
1455
- });
1456
- }
1278
+ }
1279
+ // ---------------------------------------------------------------------------
1280
+ // command_review — the direct-stage proofreading view. Read-only; no run_state
1281
+ // side effects. Two shapes:
1282
+ // * `direct review` workspace diagnostics: curation decisions +
1283
+ // issues (validation + batch extraction errors)
1284
+ // * `direct review --episode N` per-episode source.txt ↔ script.initial.json
1285
+ // side-by-side alignment (renderReviewEpisode)
1286
+ // Generic per-episode / per-asset listings live on the top-level `episodes` /
1287
+ // `assets` queries (use `--draft` to point them at script.initial.json).
1288
+ // ---------------------------------------------------------------------------
1289
+ export function commandReview(opts) {
1290
+ const workspace = strOf(opts["workspace_path"] || "workspace");
1291
+ const dd = directDir(workspace);
1292
+ const scriptPath = path.join(dd, "script.initial.json");
1293
+ const batchResultsDir = path.join(dd, "batch_results");
1294
+ const episodeArg = strOf(opts["episode"]).trim();
1295
+ // --episode: source ↔ extract alignment for the named episodes.
1296
+ if (episodeArg) {
1297
+ const episodeFilters = parseEpisodeArg(episodeArg);
1457
1298
  const sourcePath = path.join(workspace, "source.txt");
1458
1299
  if (!exists(sourcePath)) {
1459
- throw new CliError("INSPECT BLOCKED: source.txt not found", "source.txt not found.", {
1300
+ throw new CliError("REVIEW BLOCKED: source.txt not found", "source.txt not found.", {
1460
1301
  exitCode: EXIT_INPUT,
1461
1302
  required: [sourcePath],
1462
1303
  received: ["source.txt missing"],
1463
1304
  nextSteps: ["Run scriptctl direct init first."],
1464
1305
  });
1465
1306
  }
1307
+ const script = exists(scriptPath)
1308
+ ? readJson(scriptPath)
1309
+ : { episodes: [], actors: [], locations: [], props: [] };
1466
1310
  const sourceText = readText(sourcePath);
1467
1311
  const episodePlanPath = path.join(dd, "episode_plan.json");
1468
1312
  const episodePlan = exists(episodePlanPath) ? readJson(episodePlanPath) : { episodes: [] };
1313
+ const lines = [];
1469
1314
  for (let idx = 0; idx < episodeFilters.length; idx++) {
1470
1315
  if (idx > 0) {
1471
1316
  lines.push("");
@@ -1473,23 +1318,51 @@ export function commandInspect(opts) {
1473
1318
  }
1474
1319
  lines.push(...renderReviewEpisode(sourceText, episodePlan, script, episodeFilters[idx]));
1475
1320
  }
1321
+ const report = {
1322
+ title: `DIRECT REVIEW: episode ${episodeFilters.join(",")}`,
1323
+ result: lines.length > 0 ? lines : ["No matching items."],
1324
+ artifacts: [path.join(workspace, "source.txt"), path.join(dd, "episode_plan.json"), scriptPath],
1325
+ next: ["Edit with `--draft` (e.g. `scriptctl replace … --draft`) if the extraction is wrong, then validate/export."],
1326
+ };
1327
+ return [report, EXIT_OK];
1476
1328
  }
1477
- else {
1478
- throw new CliError("INSPECT BLOCKED: Invalid target", "Invalid inspect target.", {
1479
- exitCode: EXIT_USAGE,
1480
- required: ["--target: episode, asset, issue, or review"],
1481
- received: [`--target: ${target}`],
1482
- nextSteps: ["Use one of the supported inspect targets."],
1483
- });
1329
+ // No --episode: workspace-level diagnostics. Curation decisions + every issue
1330
+ // (validation issues recomputed live, plus batch extraction failures).
1331
+ const script = exists(scriptPath)
1332
+ ? readJson(scriptPath)
1333
+ : { episodes: [], actors: [], locations: [], props: [] };
1334
+ const lines = [];
1335
+ const curationPath = path.join(dd, "asset_curation.json");
1336
+ if (exists(curationPath)) {
1337
+ const curation = readJson(curationPath);
1338
+ if (isDict(curation))
1339
+ lines.push(...renderAssetCurationSummary(curation));
1340
+ }
1341
+ const batchFailures = collectBatchFailures(batchResultsDir);
1342
+ for (const errors of batchFailures.values()) {
1343
+ for (const error of errors) {
1344
+ lines.push(`error BATCH_FAILED: ${error["batch_id"]} episode ${error["episode"]} part ${error["part"]} - ${error["message"]}`);
1345
+ }
1346
+ }
1347
+ const validation = exists(scriptPath)
1348
+ ? validateScript(workspace, scriptPath, { requireSource: false })
1349
+ : { issues: [] };
1350
+ for (const issue of asList(validation["issues"])) {
1351
+ const whereParts = [];
1352
+ for (const k of ["episode", "scene", "action_index"]) {
1353
+ if (issue[k] !== null && issue[k] !== undefined)
1354
+ whereParts.push(strOf(issue[k]));
1355
+ }
1356
+ const where = whereParts.join(" ");
1357
+ lines.push(`${issue["severity"]} ${issue["code"]}: ${issue["summary"]}${where ? ` [${where}]` : ""}`);
1484
1358
  }
1485
- const state = addInspectedTarget(workspace, target);
1486
- const missingReview = reviewBlockers(state);
1487
1359
  const report = {
1488
- title: `INSPECT: ${target}`,
1489
- result: lines.length > 0 ? lines : ["No matching items."],
1360
+ title: "DIRECT REVIEW",
1361
+ result: lines.length > 0 ? lines : ["No issues, curation decisions, or batch failures to review."],
1362
+ artifacts: [scriptPath, path.join(dd, "asset_curation.json"), batchResultsDir],
1490
1363
  next: [
1491
- "Use inspect details to prepare a structured patch, or continue required review.",
1492
- missingReview.length === 0 ? "Review complete; run validate/export." : `Still inspect: ${missingReview.join(", ")}.`,
1364
+ "Run `direct review --episode <n>` for source↔extract alignment.",
1365
+ "Edit with `--draft` (e.g. `scriptctl rename --draft`), then validate/export.",
1493
1366
  ],
1494
1367
  };
1495
1368
  return [report, EXIT_OK];