@lingjingai/scriptctl 0.12.2 → 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
@@ -941,10 +878,6 @@ export async function commandInit(opts) {
941
878
  batch_reused: reusedBatchCount,
942
879
  batch_failed: erroredBatches,
943
880
  last_error: null,
944
- review_status: "pending",
945
- review_missing: [...REVIEW_TARGETS],
946
- inspected_targets: [],
947
- patch_count: 0,
948
881
  });
949
882
  const title = passed
950
883
  ? "INIT COMPLETE: Initial script ready"
@@ -961,7 +894,6 @@ export async function commandInit(opts) {
961
894
  `episodes: ${results.length}/${totalEpisodes} complete`,
962
895
  `batches: ${doneBatches}/${totalBatches} done (reused ${reusedBatchCount})`,
963
896
  ...(titleDegraded.length > 0 ? [`titles degraded (deterministic): ${titleDegraded.join(", ")}`] : []),
964
- "agent_review: pending",
965
897
  ],
966
898
  artifacts: [
967
899
  path.join(workspace, "source.txt"),
@@ -979,10 +911,10 @@ export async function commandInit(opts) {
979
911
  issues: summarizeIssues(asList(validation["issues"])),
980
912
  next: providerName === "mock"
981
913
  ? [
982
- "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.",
983
915
  "Do not export mock-provider results for delivery.",
984
916
  ]
985
- : ["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."],
986
918
  };
987
919
  return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
988
920
  }
@@ -1304,173 +1236,81 @@ function renderAssetCurationSummary(curation) {
1304
1236
  }
1305
1237
  return lines;
1306
1238
  }
1307
- export function commandInspect(opts) {
1308
- const workspace = strOf(opts["workspace_path"] || "workspace");
1309
- const target = strOf(opts["target"]);
1310
- const itemId = opts["id"] ? strOf(opts["id"]) : null;
1311
- const dd = directDir(workspace);
1312
- const scriptPath = path.join(dd, "script.initial.json");
1313
- const validationPath = path.join(dd, "validation.json");
1314
- if (!exists(scriptPath) && target === "asset") {
1315
- throw new CliError("INSPECT BLOCKED: script.initial.json not found", "script.initial.json not found.", {
1316
- exitCode: EXIT_INPUT,
1317
- required: ["workspace/draft/scriptctl/direct/script.initial.json"],
1318
- received: [scriptPath],
1319
- nextSteps: ["Run scriptctl direct init first."],
1320
- });
1321
- }
1322
- const script = exists(scriptPath)
1323
- ? readJson(scriptPath)
1324
- : { episodes: [], actors: [], locations: [], props: [] };
1325
- let validation = exists(validationPath) ? readJson(validationPath) : { issues: [] };
1326
- if (target === "issue" && exists(scriptPath)) {
1327
- validation = validateScript(workspace, scriptPath, { requireSource: false });
1328
- }
1329
- const batchPlanPath = path.join(dd, "batch_plan.json");
1330
- const batchPlan = exists(batchPlanPath) ? readJson(batchPlanPath) : { batches: [] };
1331
- const batchCounts = new Map();
1332
- for (const batch of asList(batchPlan["batches"])) {
1333
- if (!isDict(batch))
1334
- continue;
1335
- const ep = Number(batch["episode"] ?? 0);
1336
- batchCounts.set(ep, (batchCounts.get(ep) ?? 0) + 1);
1337
- }
1338
- const failedByEpisode = new Map();
1339
- const batchResultsDir = path.join(dd, "batch_results");
1340
- if (exists(batchResultsDir)) {
1341
- const files = fs.readdirSync(batchResultsDir).filter((n) => n.endsWith(".error.json")).sort();
1342
- for (const name of files) {
1343
- const errorFile = path.join(batchResultsDir, name);
1344
- try {
1345
- const error = readJson(errorFile);
1346
- const episodeNum = Number(error["episode"] ?? 0);
1347
- if (!failedByEpisode.has(episodeNum))
1348
- failedByEpisode.set(episodeNum, []);
1349
- failedByEpisode.get(episodeNum).push(strOf(error["batch_id"]) || name.replace(/\.error\.json$/, ""));
1350
- }
1351
- catch {
1352
- // ignore
1353
- }
1354
- }
1355
- }
1356
- const lines = [];
1357
- if (target === "episode") {
1358
- if (exists(scriptPath)) {
1359
- for (const ep of asList(script["episodes"])) {
1360
- if (itemId && itemId !== strOf(ep["episode_id"]))
1361
- continue;
1362
- const scenes = asList(ep["scenes"]);
1363
- const sceneCount = scenes.length;
1364
- let actionCount = 0;
1365
- for (const scene of scenes)
1366
- actionCount += asList(scene["actions"]).length;
1367
- const rawNum = normalizeInt(strOf(ep["episode_id"]), lines.length + 1);
1368
- const failed = (failedByEpisode.get(rawNum) ?? []).join(",") || "-";
1369
- lines.push(`${ep["episode_id"]}: scenes=${sceneCount}, actions=${actionCount}, batches=${batchCounts.get(rawNum) ?? 0}, failed_batches=${failed}, title=${ep["title"] || "-"}`);
1370
- }
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);
1371
1254
  }
1372
- else {
1373
- const planPath = path.join(dd, "episode_plan.json");
1374
- const plan = exists(planPath) ? readJson(planPath) : { episodes: [] };
1375
- for (const ep of asList(plan["episodes"])) {
1376
- const epId = fmtId("ep", Number(ep["episode"] ?? 0));
1377
- if (itemId && itemId !== epId && itemId !== strOf(ep["episode"]))
1378
- continue;
1379
- const episodeNum = Number(ep["episode"] ?? 0);
1380
- const failed = (failedByEpisode.get(episodeNum) ?? []).join(",") || "-";
1381
- lines.push(`${epId}: batches=${batchCounts.get(episodeNum) ?? 0}, failed_batches=${failed}, title=${ep["title"] || "-"}`);
1382
- }
1255
+ catch {
1256
+ // ignore unreadable error file
1383
1257
  }
1384
1258
  }
1385
- else if (target === "asset") {
1386
- const curationPath = path.join(dd, "asset_curation.json");
1387
- if (!itemId && exists(curationPath)) {
1388
- const curation = readJson(curationPath);
1389
- if (isDict(curation))
1390
- lines.push(...renderAssetCurationSummary(curation));
1391
- }
1392
- for (const [key, idKey, nameKey] of [["actors", "actor_id", "actor_name"], ["locations", "location_id", "location_name"], ["props", "prop_id", "prop_name"]]) {
1393
- for (const asset of asList(script[key])) {
1394
- if (itemId && itemId !== strOf(asset[idKey]) && itemId !== strOf(asset[nameKey]))
1395
- continue;
1396
- const aliases = asList(asset["aliases"]).length;
1397
- const states = asList(asset["states"]).length;
1398
- const role = key === "actors" ? `, role_type=${asset["role_type"]}` : "";
1399
- const description = strOf(asset["description"]).trim() ? "yes" : "missing";
1400
- const singular = key.slice(0, -1);
1401
- lines.push(`${singular} ${asset[idKey]}: ${asset[nameKey]} aliases=${aliases}, states=${states}${role}, description=${description}`);
1402
- }
1403
- }
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
+ });
1404
1269
  }
1405
- else if (target === "issue") {
1406
- for (const errors of failedByEpisode.values()) {
1407
- for (const batchId of errors) {
1408
- const errorPath = path.join(batchResultsDir, `${batchId}.error.json`);
1409
- if (!exists(errorPath))
1410
- continue;
1411
- let error;
1412
- try {
1413
- error = readJson(errorPath);
1414
- }
1415
- catch {
1416
- continue;
1417
- }
1418
- if (itemId && itemId !== strOf(error["batch_id"]) && itemId !== strOf(error["episode"]) && itemId !== strOf(error["error_type"]))
1419
- continue;
1420
- lines.push(`error BATCH_FAILED: ${error["batch_id"]} episode ${error["episode"]} part ${error["part"]} - ${error["message"]}`);
1421
- }
1422
- }
1423
- for (const issue of asList(validation["issues"])) {
1424
- if (itemId && itemId !== strOf(issue["code"]) && itemId !== strOf(issue["severity"]))
1425
- continue;
1426
- const whereParts = [];
1427
- for (const k of ["episode", "scene", "action_index"]) {
1428
- if (issue[k] !== null && issue[k] !== undefined)
1429
- whereParts.push(strOf(issue[k]));
1430
- }
1431
- const where = whereParts.join(" ");
1432
- lines.push(`${issue["severity"]} ${issue["code"]}: ${issue["summary"]}${where ? ` [${where}]` : ""}`);
1433
- }
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
+ });
1434
1277
  }
1435
- else if (target === "review") {
1436
- const episodeArg = strOf(opts["episode"]).trim();
1437
- if (!episodeArg) {
1438
- throw new CliError("INSPECT BLOCKED: --episode required for review", "--episode required for review.", {
1439
- exitCode: EXIT_USAGE,
1440
- required: ["--episode <n>[,<n>...]"],
1441
- received: ["--episode not provided"],
1442
- nextSteps: ["Pass --episode 1 (single) or --episode 1,15,30 (multiple)."],
1443
- });
1444
- }
1445
- let episodeFilters;
1446
- try {
1447
- episodeFilters = episodeArg.split(",").map((s) => s.trim()).filter((s) => s).map((s) => {
1448
- const n = parseInt(s, 10);
1449
- if (Number.isNaN(n))
1450
- throw new Error("nan");
1451
- return n;
1452
- });
1453
- }
1454
- catch {
1455
- throw new CliError("INSPECT BLOCKED: --episode must be integers", "--episode must be integers.", {
1456
- exitCode: EXIT_USAGE,
1457
- required: ["--episode <n>[,<n>...]"],
1458
- received: [`--episode ${episodeArg}`],
1459
- nextSteps: ["Pass episode numbers like --episode 1,15,30."],
1460
- });
1461
- }
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);
1462
1298
  const sourcePath = path.join(workspace, "source.txt");
1463
1299
  if (!exists(sourcePath)) {
1464
- 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.", {
1465
1301
  exitCode: EXIT_INPUT,
1466
1302
  required: [sourcePath],
1467
1303
  received: ["source.txt missing"],
1468
1304
  nextSteps: ["Run scriptctl direct init first."],
1469
1305
  });
1470
1306
  }
1307
+ const script = exists(scriptPath)
1308
+ ? readJson(scriptPath)
1309
+ : { episodes: [], actors: [], locations: [], props: [] };
1471
1310
  const sourceText = readText(sourcePath);
1472
1311
  const episodePlanPath = path.join(dd, "episode_plan.json");
1473
1312
  const episodePlan = exists(episodePlanPath) ? readJson(episodePlanPath) : { episodes: [] };
1313
+ const lines = [];
1474
1314
  for (let idx = 0; idx < episodeFilters.length; idx++) {
1475
1315
  if (idx > 0) {
1476
1316
  lines.push("");
@@ -1478,23 +1318,51 @@ export function commandInspect(opts) {
1478
1318
  }
1479
1319
  lines.push(...renderReviewEpisode(sourceText, episodePlan, script, episodeFilters[idx]));
1480
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];
1481
1328
  }
1482
- else {
1483
- throw new CliError("INSPECT BLOCKED: Invalid target", "Invalid inspect target.", {
1484
- exitCode: EXIT_USAGE,
1485
- required: ["--target: episode, asset, issue, or review"],
1486
- received: [`--target: ${target}`],
1487
- nextSteps: ["Use one of the supported inspect targets."],
1488
- });
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}]` : ""}`);
1489
1358
  }
1490
- const state = addInspectedTarget(workspace, target);
1491
- const missingReview = reviewBlockers(state);
1492
1359
  const report = {
1493
- title: `INSPECT: ${target}`,
1494
- 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],
1495
1363
  next: [
1496
- "Use inspect details to prepare a structured patch, or continue required review.",
1497
- 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.",
1498
1366
  ],
1499
1367
  };
1500
1368
  return [report, EXIT_OK];