@lingjingai/scriptctl 0.28.4 → 0.29.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.
Files changed (116) hide show
  1. package/changes/0.29.0.md +17 -0
  2. package/dist/cli.js +25 -3
  3. package/dist/cli.js.map +1 -1
  4. package/dist/common.d.ts +1 -0
  5. package/dist/common.js +3 -0
  6. package/dist/common.js.map +1 -1
  7. package/dist/domain/direct/runner.d.ts +23 -0
  8. package/dist/domain/direct/runner.js +72 -0
  9. package/dist/domain/direct/runner.js.map +1 -0
  10. package/dist/domain/direct/stage.d.ts +86 -0
  11. package/dist/domain/direct/stage.js +131 -0
  12. package/dist/domain/direct/stage.js.map +1 -0
  13. package/dist/domain/direct/stages/asset-curation.d.ts +2 -0
  14. package/dist/domain/direct/stages/asset-curation.js +9 -0
  15. package/dist/domain/direct/stages/asset-curation.js.map +1 -0
  16. package/dist/domain/direct/stages/batch-extract.d.ts +2 -0
  17. package/dist/domain/direct/stages/batch-extract.js +12 -0
  18. package/dist/domain/direct/stages/batch-extract.js.map +1 -0
  19. package/dist/domain/direct/stages/batch-plan.d.ts +2 -0
  20. package/dist/domain/direct/stages/batch-plan.js +9 -0
  21. package/dist/domain/direct/stages/batch-plan.js.map +1 -0
  22. package/dist/domain/direct/stages/episode-merge.d.ts +2 -0
  23. package/dist/domain/direct/stages/episode-merge.js +11 -0
  24. package/dist/domain/direct/stages/episode-merge.js.map +1 -0
  25. package/dist/domain/direct/stages/episode-plan.d.ts +2 -0
  26. package/dist/domain/direct/stages/episode-plan.js +9 -0
  27. package/dist/domain/direct/stages/episode-plan.js.map +1 -0
  28. package/dist/domain/direct/stages/episode-synopsis.d.ts +2 -0
  29. package/dist/domain/direct/stages/episode-synopsis.js +12 -0
  30. package/dist/domain/direct/stages/episode-synopsis.js.map +1 -0
  31. package/dist/domain/direct/stages/episode-titles.d.ts +2 -0
  32. package/dist/domain/direct/stages/episode-titles.js +12 -0
  33. package/dist/domain/direct/stages/episode-titles.js.map +1 -0
  34. package/dist/domain/direct/stages/index.d.ts +17 -0
  35. package/dist/domain/direct/stages/index.js +37 -0
  36. package/dist/domain/direct/stages/index.js.map +1 -0
  37. package/dist/domain/direct/stages/metadata.d.ts +2 -0
  38. package/dist/domain/direct/stages/metadata.js +11 -0
  39. package/dist/domain/direct/stages/metadata.js.map +1 -0
  40. package/dist/domain/direct/stages/script-merge.d.ts +2 -0
  41. package/dist/domain/direct/stages/script-merge.js +15 -0
  42. package/dist/domain/direct/stages/script-merge.js.map +1 -0
  43. package/dist/domain/direct/stages/script-synopsis.d.ts +2 -0
  44. package/dist/domain/direct/stages/script-synopsis.js +12 -0
  45. package/dist/domain/direct/stages/script-synopsis.js.map +1 -0
  46. package/dist/domain/direct/stages/source-prepare.d.ts +2 -0
  47. package/dist/domain/direct/stages/source-prepare.js +9 -0
  48. package/dist/domain/direct/stages/source-prepare.js.map +1 -0
  49. package/dist/domain/direct/stages/validate.d.ts +2 -0
  50. package/dist/domain/direct/stages/validate.js +10 -0
  51. package/dist/domain/direct/stages/validate.js.map +1 -0
  52. package/dist/domain/direct-core.js +15 -1
  53. package/dist/domain/direct-core.js.map +1 -1
  54. package/dist/help-text.js +1 -1
  55. package/dist/infra/local-script-output-store.d.ts +2 -1
  56. package/dist/infra/local-script-output-store.js +11 -0
  57. package/dist/infra/local-script-output-store.js.map +1 -1
  58. package/dist/infra/providers.js +13 -4
  59. package/dist/infra/providers.js.map +1 -1
  60. package/dist/infra/script-output-api.d.ts +27 -4
  61. package/dist/infra/script-output-api.js +130 -106
  62. package/dist/infra/script-output-api.js.map +1 -1
  63. package/dist/infra/script-output-store.d.ts +24 -0
  64. package/dist/infra/script-output-store.js.map +1 -1
  65. package/dist/usecases/direct.d.ts +14 -0
  66. package/dist/usecases/direct.js +701 -358
  67. package/dist/usecases/direct.js.map +1 -1
  68. package/dist/usecases/parse.js +2 -2
  69. package/dist/usecases/parse.js.map +1 -1
  70. package/dist/usecases/script/actions.js.map +1 -1
  71. package/dist/usecases/script/actor.js.map +1 -1
  72. package/dist/usecases/script/actors.js.map +1 -1
  73. package/dist/usecases/script/add-actor.js.map +1 -1
  74. package/dist/usecases/script/add-episode.js.map +1 -1
  75. package/dist/usecases/script/add-location.js.map +1 -1
  76. package/dist/usecases/script/add-prop.js.map +1 -1
  77. package/dist/usecases/script/add-speaker.js.map +1 -1
  78. package/dist/usecases/script/alias.js.map +1 -1
  79. package/dist/usecases/script/assets.js.map +1 -1
  80. package/dist/usecases/script/context.js.map +1 -1
  81. package/dist/usecases/script/create.js.map +1 -1
  82. package/dist/usecases/script/delete.js.map +1 -1
  83. package/dist/usecases/script/describe.js.map +1 -1
  84. package/dist/usecases/script/dialogue.js.map +1 -1
  85. package/dist/usecases/script/do.js.map +1 -1
  86. package/dist/usecases/script/episodes.js.map +1 -1
  87. package/dist/usecases/script/export.js.map +1 -1
  88. package/dist/usecases/script/insert.js.map +1 -1
  89. package/dist/usecases/script/issues.js.map +1 -1
  90. package/dist/usecases/script/lib.js.map +1 -1
  91. package/dist/usecases/script/locations.js.map +1 -1
  92. package/dist/usecases/script/merge.js.map +1 -1
  93. package/dist/usecases/script/move.js.map +1 -1
  94. package/dist/usecases/script/overlap.js.map +1 -1
  95. package/dist/usecases/script/props.js.map +1 -1
  96. package/dist/usecases/script/refs.js.map +1 -1
  97. package/dist/usecases/script/rename.js.map +1 -1
  98. package/dist/usecases/script/replace.js.map +1 -1
  99. package/dist/usecases/script/role.js.map +1 -1
  100. package/dist/usecases/script/scenes.js.map +1 -1
  101. package/dist/usecases/script/script-patch.js.map +1 -1
  102. package/dist/usecases/script/script-validate.js.map +1 -1
  103. package/dist/usecases/script/session.d.ts +1 -0
  104. package/dist/usecases/script/session.js +15 -4
  105. package/dist/usecases/script/session.js.map +1 -1
  106. package/dist/usecases/script/speakers.js.map +1 -1
  107. package/dist/usecases/script/split.js.map +1 -1
  108. package/dist/usecases/script/state-add.js.map +1 -1
  109. package/dist/usecases/script/state-change.js.map +1 -1
  110. package/dist/usecases/script/state-delete.js.map +1 -1
  111. package/dist/usecases/script/state-rename.js.map +1 -1
  112. package/dist/usecases/script/summary.js.map +1 -1
  113. package/dist/usecases/script/transition.js.map +1 -1
  114. package/dist/usecases/script/type.js.map +1 -1
  115. package/dist/usecases/script/worldview.js.map +1 -1
  116. package/package.json +2 -1
@@ -1,10 +1,18 @@
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, 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, mergeEpisodeResultsWithReport, normalizeEpisodeResult, planSynopsisReduction, readCarriedSynopsis, sortDeep, 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_DIRECT_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, expandCompactEpisodeResult, 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";
8
+ import { Workspace, StageIncomplete, emptyRunState, stageRunStateName, } from "../domain/direct/stage.js";
9
+ // Circular import: runner.ts → stages/index.ts → each stage file → THIS module
10
+ // (the stageXxx impls live here). It's safe because every reference below
11
+ // (runStages / runOne / STAGES) is used only inside function bodies that run at
12
+ // CALL time (commandInitImpl, commandRun), never at module-load time — so the
13
+ // cycle is fully resolved before any of them executes.
14
+ import { runStages, runOne } from "../domain/direct/runner.js";
15
+ import { STAGES } from "../domain/direct/stages/index.js";
8
16
  function strOf(v) {
9
17
  if (v === null || v === undefined)
10
18
  return "";
@@ -994,6 +1002,21 @@ function initFailedReport(workspace, opts) {
994
1002
  nextSteps: opts.nextSteps,
995
1003
  });
996
1004
  }
1005
+ // Catch→initFailedReport for the deterministic stages: format the raw exception
1006
+ // into a single-line `received` and write the failure run_state, so the cast +
1007
+ // `slice(0,160)` + updates boilerplate lives here instead of being hand-copied
1008
+ // into every stage's catch block.
1009
+ function stageFailure(workspace, exc, opts) {
1010
+ const e = exc;
1011
+ throw initFailedReport(workspace, {
1012
+ title: opts.title,
1013
+ stage: opts.stage,
1014
+ required: opts.required,
1015
+ received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1016
+ nextSteps: opts.nextSteps,
1017
+ ...(opts.updates ? { updates: opts.updates } : {}),
1018
+ });
1019
+ }
997
1020
  // ---------------------------------------------------------------------------
998
1021
  // Concurrency limiter (mirrors ThreadPoolExecutor)
999
1022
  // ---------------------------------------------------------------------------
@@ -1303,7 +1326,11 @@ export async function commandInit(opts) {
1303
1326
  finishInitProgress();
1304
1327
  }
1305
1328
  }
1306
- async function commandInitImpl(opts) {
1329
+ // Parse `direct init` / `direct run` options into the typed DirectRunOptions the
1330
+ // stage pipeline consumes. Single source of truth for flag parsing + validation
1331
+ // so init and run can't drift on defaults (--concurrency / batch sizing / the
1332
+ // selection flags). Throws the same USAGE/INPUT CliErrors the god-command did.
1333
+ function parseDirectRunOptions(opts) {
1307
1334
  const sourcePathArg = strOf(opts["source_path"]);
1308
1335
  const source = sourcePathArg.startsWith("~")
1309
1336
  ? path.join(process.env.HOME ?? "", sourcePathArg.slice(1))
@@ -1327,7 +1354,7 @@ async function commandInitImpl(opts) {
1327
1354
  const skipMetadata = Boolean(opts["skip_metadata"]);
1328
1355
  const resummarize = Boolean(opts["resummarize"]);
1329
1356
  const summaryThresholdChars = parsePositiveIntOption(opts["summary_threshold_chars"], "--summary-threshold-chars", "USAGE ERROR: Invalid summary threshold", DEFAULT_SUMMARY_REDUCE_THRESHOLD_CHARS);
1330
- const concurrency = parsePositiveIntOption(opts["concurrency"], "--concurrency", "USAGE ERROR: Invalid concurrency", DEFAULT_CONCURRENCY);
1357
+ const concurrency = parsePositiveIntOption(opts["concurrency"], "--concurrency", "USAGE ERROR: Invalid concurrency", DEFAULT_DIRECT_CONCURRENCY);
1331
1358
  const batchMode = strOf(opts["batch_mode"] || DEFAULT_BATCH_MODE).trim();
1332
1359
  if (batchMode !== "episode") {
1333
1360
  throw new CliError("USAGE ERROR: Invalid batch mode", "Invalid batch mode.", {
@@ -1348,6 +1375,66 @@ async function commandInitImpl(opts) {
1348
1375
  const batchTargetLines = batchValues["batch_target_lines"];
1349
1376
  const batchMaxChars = batchValues["batch_max_chars"];
1350
1377
  const batchMinLines = Math.min(batchValues["batch_min_lines"], batchTargetLines);
1378
+ return {
1379
+ source,
1380
+ workspace,
1381
+ providerName,
1382
+ model,
1383
+ concurrency,
1384
+ batchMode,
1385
+ batchTargetLines,
1386
+ batchMaxChars,
1387
+ batchMinLines,
1388
+ selEpisodes,
1389
+ selBatches,
1390
+ retryErrors,
1391
+ runAll,
1392
+ skipSummary,
1393
+ skipMetadata,
1394
+ resummarize,
1395
+ summaryThresholdChars,
1396
+ };
1397
+ }
1398
+ // Build the per-stage progress emitter + run_state updater + lazy provider that
1399
+ // every stage shares. Extracted so commandInit and commandRun assemble an
1400
+ // identical StageContext (same progress rendering, same run_state side effects).
1401
+ function buildStageContext(options) {
1402
+ const ws = new Workspace(options.workspace);
1403
+ let provider = null;
1404
+ const ctx = {
1405
+ ws,
1406
+ options,
1407
+ state: emptyRunState(),
1408
+ provider() {
1409
+ if (provider)
1410
+ return provider;
1411
+ try {
1412
+ provider = makeProvider(options.providerName, options.model);
1413
+ }
1414
+ catch (exc) {
1415
+ if (exc instanceof CliError) {
1416
+ updateRunState(options.workspace, {
1417
+ status: "init_failed",
1418
+ init_stage: "episode_titles",
1419
+ last_error: { title: exc.title, received: exc.received, failed_at: checkpointTimestamp() },
1420
+ });
1421
+ }
1422
+ throw exc;
1423
+ }
1424
+ return provider;
1425
+ },
1426
+ emitProgress(stage, status = "running") {
1427
+ emitInitProgress(options.workspace, stageRunStateName(stage), status);
1428
+ },
1429
+ updateRunState(updates) {
1430
+ updateRunState(options.workspace, updates);
1431
+ },
1432
+ };
1433
+ return ctx;
1434
+ }
1435
+ // Validate --source-path (existence + supported extension) before any stage
1436
+ // runs. Kept as the init-only entry guard the god-command performed up front.
1437
+ function assertInitSource(source) {
1351
1438
  if (!exists(source) || !fs.statSync(source).isFile()) {
1352
1439
  throw new CliError("INIT BLOCKED: Source file not found", "Source file not found.", {
1353
1440
  exitCode: EXIT_INPUT,
@@ -1366,26 +1453,350 @@ async function commandInitImpl(opts) {
1366
1453
  nextSteps: ["Use a supported source file and rerun init."],
1367
1454
  });
1368
1455
  }
1369
- const dd = directDir(workspace);
1370
- fs.mkdirSync(dd, { recursive: true });
1371
- updateRunState(workspace, {
1456
+ }
1457
+ // Build the final INIT report from the completed run state. Reads the same
1458
+ // artifacts/state the god-command's trailing block read, so the report lines,
1459
+ // artifacts list and exit code are byte-identical.
1460
+ function buildInitReport(ctx) {
1461
+ const { ws, options, state } = ctx;
1462
+ const dd = ws.dir;
1463
+ const validation = state.validation ?? {};
1464
+ const passed = Boolean(validation["passed"]);
1465
+ const title = passed
1466
+ ? "INIT COMPLETE: Initial script ready"
1467
+ : "INIT NEEDS AGENT: Initial script written with repair issues";
1468
+ const stats = validation["stats"] ?? {};
1469
+ const summaryDegraded = state.summaryFullDegraded || state.summaryDegradedEpisodes.length > 0;
1470
+ const summaryLine = formatSummaryStatusLine(options.skipSummary, summaryDegraded, state.summaryDegradedEpisodes);
1471
+ const normalization = isDict(state.assetNormalization) ? state.assetNormalization : null;
1472
+ const normSummary = normalization && isDict(normalization["summary"]) ? normalization["summary"] : {};
1473
+ const merges = Number(normSummary["asset_merges"] ?? 0);
1474
+ const statesFromNames = Number(normSummary["location_states_from_names"] ?? 0);
1475
+ const statesFromTime = Number(normSummary["location_states_from_time"] ?? 0);
1476
+ const reviewItems = Number(normSummary["review_items"] ?? 0);
1477
+ const report = {
1478
+ title,
1479
+ result: [
1480
+ `episodes: ${stats["episodes"] ?? 0}`,
1481
+ `scenes: ${stats["scenes"] ?? 0}`,
1482
+ `actions: ${stats["actions"] ?? 0}`,
1483
+ `validation: ${passed ? "passed" : "needs repair"}`,
1484
+ `provider: ${options.providerName}`,
1485
+ `episodes: ${state.mergedResults.length}/${state.totalEpisodes} complete`,
1486
+ `content: ${state.doneBatches}/${state.totalBatches} done`,
1487
+ `normalization: merges=${merges}, states=${statesFromNames + statesFromTime}, review=${reviewItems}`,
1488
+ ...(options.skipMetadata ? ["metadata: skipped"] : []),
1489
+ ...(state.titleDegraded.length > 0 ? [`titles degraded (deterministic): ${state.titleDegraded.join(", ")}`] : []),
1490
+ ...(summaryLine ? [summaryLine] : []),
1491
+ ],
1492
+ artifacts: [
1493
+ ws.rootPathOf("source.txt"),
1494
+ path.join(dd, "source_manifest.json"),
1495
+ path.join(dd, "episode_plan.json"),
1496
+ path.join(dd, "batch_plan.json"),
1497
+ path.join(dd, "batch_results"),
1498
+ path.join(dd, "episode_results"),
1499
+ path.join(dd, "asset_normalization.json"),
1500
+ path.join(dd, "asset_curation.json"),
1501
+ ...(options.skipMetadata ? [] : [path.join(dd, "asset_metadata.json")]),
1502
+ path.join(dd, "synopsis.json"),
1503
+ path.join(dd, "script.initial.json"),
1504
+ path.join(dd, "validation.json"),
1505
+ path.join(dd, "run_state.json"),
1506
+ ],
1507
+ issues: summarizeIssues(asList(validation["issues"])),
1508
+ next: options.providerName === "mock"
1509
+ ? [
1510
+ "Run `direct review` (add --episode <n> for source↔extract); edit with --draft if needed; then validate/export.",
1511
+ "Do not export mock-provider results for delivery.",
1512
+ ]
1513
+ : ["Run `direct review` (add --episode <n> for source↔extract); edit with --draft if needed; then validate/export."],
1514
+ };
1515
+ return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
1516
+ }
1517
+ // Build the INIT INCOMPLETE report (some episode still has unextracted batches).
1518
+ // Mirrors the god-command's early-return block; EXIT_RUNTIME signals automation.
1519
+ function buildIncompleteReport(ctx) {
1520
+ const { ws, options, state } = ctx;
1521
+ const dd = ws.dir;
1522
+ const report = {
1523
+ title: "INIT INCOMPLETE: Some episodes have unextracted batches",
1524
+ result: [
1525
+ `episodes: ${state.mergedResults.length}/${state.totalEpisodes} complete`,
1526
+ `incomplete episodes: ${state.incompleteEpisodes.join(", ")}`,
1527
+ `content: ${state.doneBatches}/${state.totalBatches} done, ${state.erroredBatches} error`,
1528
+ ...(state.titleDegraded.length > 0 ? [`titles degraded (deterministic): ${state.titleDegraded.join(", ")}`] : []),
1529
+ `provider: ${options.providerName}`,
1530
+ ],
1531
+ artifacts: [
1532
+ ws.rootPathOf("source.txt"),
1533
+ path.join(dd, "episode_plan.json"),
1534
+ path.join(dd, "batch_plan.json"),
1535
+ path.join(dd, "batch_results"),
1536
+ path.join(dd, "episode_results"),
1537
+ path.join(dd, "run_state.json"),
1538
+ ],
1539
+ next: [
1540
+ "Run `direct status` to see episode progress.",
1541
+ "Re-run one episode with `direct init --episodes <n>`, or all errors with `--retry-errors`.",
1542
+ "Errored units are left untouched on a plain rerun; select them to retry.",
1543
+ ],
1544
+ };
1545
+ return [report, EXIT_RUNTIME];
1546
+ }
1547
+ async function commandInitImpl(opts) {
1548
+ const options = parseDirectRunOptions(opts);
1549
+ assertInitSource(options.source);
1550
+ const ctx = buildStageContext(options);
1551
+ ctx.ws.ensure();
1552
+ // Seed run_state with the init header before the first stage runs (provider /
1553
+ // model / source_path / metadata flag) — the god-command set these up front.
1554
+ updateRunState(options.workspace, {
1372
1555
  status: "init_running",
1373
1556
  command: "direct init",
1374
1557
  init_stage: "source_prepare",
1375
- provider: providerName,
1376
- model,
1377
- concurrency,
1378
- source_path: path.resolve(source),
1379
- metadata_skipped: skipMetadata,
1558
+ provider: options.providerName,
1559
+ model: options.model,
1560
+ concurrency: options.concurrency,
1561
+ source_path: path.resolve(options.source),
1562
+ metadata_skipped: options.skipMetadata,
1380
1563
  });
1381
- emitInitProgress(workspace, "source_prepare");
1564
+ try {
1565
+ // `direct init` runs EVERY stage (force) — exactly like the former god
1566
+ // command, which had no whole-stage skip. The reuse model lives INSIDE the
1567
+ // stages (per-batch file presence in batch-extract; per-episode `syn` reuse
1568
+ // in the synopsis stages), so a deterministic stage must still re-derive its
1569
+ // output from the (possibly reused/edited) upstream artifacts. Whole-stage
1570
+ // skip-on-present is a `direct run <stage>` convenience only.
1571
+ await runStages(ctx, STAGES, { force: true });
1572
+ }
1573
+ catch (exc) {
1574
+ // The resumable "incomplete" stop is not a failure — surface the INCOMPLETE
1575
+ // report + EXIT_RUNTIME, exactly as the god-command's early return did.
1576
+ if (exc instanceof StageIncomplete)
1577
+ return buildIncompleteReport(ctx);
1578
+ throw exc;
1579
+ }
1580
+ // Pipeline complete: finalize run_state (the trailing god-command block).
1581
+ const validation = ctx.state.validation ?? {};
1582
+ const passed = Boolean(validation["passed"]);
1583
+ const status = passed ? "ready_for_agent" : "needs_agent_repair";
1584
+ const summaryDegraded = ctx.state.summaryFullDegraded || ctx.state.summaryDegradedEpisodes.length > 0;
1585
+ updateRunState(options.workspace, {
1586
+ status,
1587
+ command: "direct init",
1588
+ init_stage: "complete",
1589
+ provider: options.providerName,
1590
+ model: options.model,
1591
+ concurrency: options.concurrency,
1592
+ batch_mode: options.batchMode,
1593
+ batch_target_lines: options.batchTargetLines,
1594
+ batch_max_chars: options.batchMaxChars,
1595
+ batch_min_lines: options.batchMinLines,
1596
+ source_path: path.resolve(options.source),
1597
+ script_path: ctx.ws.pathOf("script.initial.json"),
1598
+ validation_path: ctx.ws.pathOf("validation.json"),
1599
+ episode_total: ctx.state.totalEpisodes,
1600
+ episode_completed: ctx.state.mergedResults.length,
1601
+ incomplete_episodes: [],
1602
+ batch_total: ctx.state.totalBatches,
1603
+ batch_completed: ctx.state.doneBatches,
1604
+ batch_reused: ctx.state.reusedBatchCount,
1605
+ batch_failed: ctx.state.erroredBatches,
1606
+ summary_threshold_chars: options.summaryThresholdChars,
1607
+ summary_skipped: options.skipSummary,
1608
+ summary_degraded: summaryDegraded,
1609
+ summary_degraded_episodes: ctx.state.summaryDegradedEpisodes,
1610
+ summary_degraded_reason: ctx.state.summaryDegradeReason || null,
1611
+ metadata_skipped: options.skipMetadata,
1612
+ last_error: null,
1613
+ });
1614
+ return buildInitReport(ctx);
1615
+ }
1616
+ // ---------------------------------------------------------------------------
1617
+ // command_run — single-stage runner against an existing workspace. Lets the
1618
+ // agent re-run one step (e.g. `direct run metadata`, `direct run episode-synopsis`)
1619
+ // without re-driving the whole pipeline. The stage rehydrates the prior
1620
+ // artifacts it needs from disk, so it stands alone.
1621
+ // ---------------------------------------------------------------------------
1622
+ export async function commandRun(opts) {
1623
+ const stageName = strOf(asList(opts["_args"])[0] || opts["stage"]).trim();
1624
+ if (!stageName) {
1625
+ throw new CliError("DIRECT RUN BLOCKED: missing stage", "No stage name given.", {
1626
+ exitCode: EXIT_USAGE,
1627
+ required: [`one of: ${STAGES.map((s) => s.name).join(", ")}`],
1628
+ received: ["<no stage>"],
1629
+ nextSteps: ["Pass a stage name, e.g. `direct run metadata`."],
1630
+ });
1631
+ }
1632
+ // Reject an unknown stage up front (both code paths below would otherwise
1633
+ // silently no-op the `only`-filtered runStages on a typo).
1634
+ if (!STAGES.some((s) => s.name === stageName)) {
1635
+ throw new CliError("DIRECT RUN BLOCKED: unknown stage", `Unknown stage "${stageName}".`, {
1636
+ exitCode: EXIT_USAGE,
1637
+ required: [`one of: ${STAGES.map((s) => s.name).join(", ")}`],
1638
+ received: [stageName],
1639
+ nextSteps: ["Pass a valid stage name to `direct run`."],
1640
+ });
1641
+ }
1642
+ const options = parseDirectRunOptions(opts);
1643
+ const ctx = buildStageContext(options);
1644
+ const force = Boolean(opts["force"]);
1645
+ try {
1646
+ if (force) {
1647
+ // --force re-runs even when the output artifact is already present.
1648
+ await runOne(ctx, stageName);
1649
+ }
1650
+ else {
1651
+ // Without --force, honor skip-on-present so `direct run <stage>` is a no-op
1652
+ // when the stage's output already exists (mirrors runStages' resume).
1653
+ await runStages(ctx, STAGES, { only: new Set([stageName]) });
1654
+ }
1655
+ }
1656
+ catch (exc) {
1657
+ if (exc instanceof StageIncomplete) {
1658
+ finishInitProgress();
1659
+ return buildIncompleteReport(ctx);
1660
+ }
1661
+ emitFailedInitProgress(options.workspace);
1662
+ finishInitProgress();
1663
+ throw exc;
1664
+ }
1665
+ finishInitProgress();
1666
+ const report = {
1667
+ title: `DIRECT RUN: ${stageName}`,
1668
+ result: [
1669
+ `stage: ${stageName}`,
1670
+ `workspace: ${options.workspace}`,
1671
+ "ran one stage; run `direct status` for the full picture.",
1672
+ ],
1673
+ artifacts: [ctx.ws.pathOf("run_state.json")],
1674
+ next: ["Run `direct status` to see updated progress, or `direct run <stage>` for another step."],
1675
+ };
1676
+ return [report, EXIT_OK];
1677
+ }
1678
+ // ===========================================================================
1679
+ // Per-stage implementations. Each function below is ONE segment of the former
1680
+ // commandInitImpl, lifted verbatim onto a StageContext so it can run either as
1681
+ // part of the full pipeline (runStages) or on its own (`direct run <stage>`).
1682
+ // They call the SAME helpers/pure functions the god-command used — no algorithm
1683
+ // is duplicated; only the cross-stage locals moved onto ctx.state. The thin
1684
+ // Stage wrappers in src/domain/direct/stages/*.ts just name + order these.
1685
+ // ===========================================================================
1686
+ // A stage that needs a prior artifact missing from ctx.state (because the run
1687
+ // started at a later stage via `direct run`) lazily rehydrates it from disk.
1688
+ function ensureEpisodePlan(ctx) {
1689
+ if (ctx.state.episodePlan)
1690
+ return ctx.state.episodePlan;
1691
+ const plan = ctx.ws.readJsonSafe("episode_plan.json");
1692
+ if (!plan) {
1693
+ throw new CliError("DIRECT RUN BLOCKED: episode_plan.json missing", "episode_plan.json missing.", {
1694
+ exitCode: EXIT_INPUT,
1695
+ required: ["episode_plan.json (run the episode-plan stage first)"],
1696
+ received: ["episode_plan.json missing"],
1697
+ nextSteps: ["Run `direct init` or `direct run episode-plan` first."],
1698
+ });
1699
+ }
1700
+ ctx.state.episodePlan = plan;
1701
+ return plan;
1702
+ }
1703
+ function ensureBatchPlan(ctx) {
1704
+ if (ctx.state.batchPlan)
1705
+ return ctx.state.batchPlan;
1706
+ const plan = ctx.ws.readJsonSafe("batch_plan.json");
1707
+ if (!plan) {
1708
+ throw new CliError("DIRECT RUN BLOCKED: batch_plan.json missing", "batch_plan.json missing.", {
1709
+ exitCode: EXIT_INPUT,
1710
+ required: ["batch_plan.json (run the batch-plan stage first)"],
1711
+ received: ["batch_plan.json missing"],
1712
+ nextSteps: ["Run `direct init` or `direct run batch-plan` first."],
1713
+ });
1714
+ }
1715
+ ctx.state.batchPlan = plan;
1716
+ return plan;
1717
+ }
1718
+ function ensureSourceText(ctx) {
1719
+ if (ctx.state.sourceText)
1720
+ return ctx.state.sourceText;
1721
+ const p = ctx.ws.rootPathOf("source.txt");
1722
+ if (!exists(p)) {
1723
+ throw new CliError("DIRECT RUN BLOCKED: source.txt missing", "source.txt missing.", {
1724
+ exitCode: EXIT_INPUT,
1725
+ required: ["source.txt (run the source-prepare stage first)"],
1726
+ received: ["source.txt missing"],
1727
+ nextSteps: ["Run `direct init` or `direct run source-prepare` first."],
1728
+ });
1729
+ }
1730
+ ctx.state.sourceText = readText(p);
1731
+ return ctx.state.sourceText;
1732
+ }
1733
+ function ensureScript(ctx) {
1734
+ if (ctx.state.script)
1735
+ return ctx.state.script;
1736
+ const script = ctx.ws.readJsonSafe("script.initial.json");
1737
+ if (!script) {
1738
+ throw new CliError("DIRECT RUN BLOCKED: script.initial.json missing", "script.initial.json missing.", {
1739
+ exitCode: EXIT_INPUT,
1740
+ required: ["script.initial.json (run the script-merge stage first)"],
1741
+ received: ["script.initial.json missing"],
1742
+ nextSteps: ["Run `direct init` or `direct run script-merge` first."],
1743
+ });
1744
+ }
1745
+ ctx.state.script = script;
1746
+ return script;
1747
+ }
1748
+ // Reassemble ctx.state.mergedResults from episode_results/ when a later stage is
1749
+ // run standalone (e.g. `direct run script-merge`). Mirrors the assembly the
1750
+ // episode-merge stage does in-memory, but reads the persisted results.
1751
+ function ensureMergedResults(ctx) {
1752
+ if (ctx.state.mergedResults.length > 0)
1753
+ return ctx.state.mergedResults;
1754
+ const plan = ensureEpisodePlan(ctx);
1755
+ const episodeResultsDir = ctx.ws.pathOf("episode_results");
1756
+ const results = [];
1757
+ for (const episode of asList(plan["episodes"])) {
1758
+ const p = episodeResultPath(episodeResultsDir, episode);
1759
+ const data = exists(p) ? readJsonFile(p, {}) : null;
1760
+ if (data && isDict(data) && Object.keys(data).length > 0) {
1761
+ results.push(expandCompactEpisodeResult(data, episode));
1762
+ }
1763
+ }
1764
+ results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
1765
+ ctx.state.mergedResults = results;
1766
+ return results;
1767
+ }
1768
+ // Recover the project name when a script-producing stage runs standalone (no
1769
+ // source-prepare set state.projectName, and `direct run` has no --source-path).
1770
+ // Order: in-memory → persisted manifest (source_name minus ext) → existing
1771
+ // script title → source basename. Without this, `direct run script-merge` would
1772
+ // rebuild the script with an empty title (state.projectName='' && source='').
1773
+ function ensureProjectName(ctx) {
1774
+ if (ctx.state.projectName)
1775
+ return ctx.state.projectName;
1776
+ const manifest = ctx.ws.readJsonSafe("source_manifest.json");
1777
+ const fromManifest = manifest
1778
+ ? strOf(manifest["source_name"]).replace(/\.[^.]+$/, "")
1779
+ || (strOf(manifest["source_path"]) ? path.basename(strOf(manifest["source_path"]), path.extname(strOf(manifest["source_path"]))) : "")
1780
+ : "";
1781
+ const fromScript = strOf(ctx.ws.readJsonSafe("script.initial.json")?.["title"]);
1782
+ const fromSource = ctx.options.source ? path.basename(ctx.options.source, path.extname(ctx.options.source)) : "";
1783
+ const name = fromManifest || fromScript || fromSource;
1784
+ ctx.state.projectName = name;
1785
+ return name;
1786
+ }
1787
+ // --- source-prepare -------------------------------------------------------
1788
+ export async function stageSourcePrepare(ctx) {
1789
+ const { options, ws, state } = ctx;
1790
+ const source = options.source;
1791
+ ctx.updateRunState({ status: "init_running", command: "direct init", init_stage: "source_prepare" });
1792
+ ctx.emitProgress("source-prepare");
1382
1793
  let info;
1383
1794
  try {
1384
- info = await prepareSource(source, workspace);
1795
+ info = await prepareSource(source, options.workspace);
1385
1796
  }
1386
1797
  catch (exc) {
1387
1798
  if (exc instanceof CliError) {
1388
- updateRunState(workspace, {
1799
+ ctx.updateRunState({
1389
1800
  status: "init_failed",
1390
1801
  init_stage: "source_prepare",
1391
1802
  last_error: { title: exc.title, received: exc.received, failed_at: checkpointTimestamp() },
@@ -1394,7 +1805,7 @@ async function commandInitImpl(opts) {
1394
1805
  }
1395
1806
  const e = exc;
1396
1807
  const receivedError = `${source}: ${e?.name ?? "Error"}${e?.message ? `: ${e.message}` : ""}`;
1397
- updateRunState(workspace, {
1808
+ ctx.updateRunState({
1398
1809
  status: "init_failed",
1399
1810
  init_stage: "source_prepare",
1400
1811
  last_error: { title: "INIT BLOCKED: Source preparation failed", received: [receivedError], failed_at: checkpointTimestamp() },
@@ -1407,108 +1818,102 @@ async function commandInitImpl(opts) {
1407
1818
  });
1408
1819
  }
1409
1820
  const sourceTextPath = strOf(info["sourceTextPath"]);
1410
- const sourceText = readText(sourceTextPath);
1411
- const manifest = makeSourceManifest(source, sourceTextPath, info);
1412
- updateRunState(workspace, { status: "init_running", init_stage: "episode_plan" });
1413
- emitInitProgress(workspace, "episode_plan");
1821
+ state.sourceText = readText(sourceTextPath);
1822
+ state.projectName = strOf(info["projectName"]) || path.basename(source, path.extname(source));
1823
+ state.manifest = makeSourceManifest(source, sourceTextPath, info);
1824
+ ws.writeJson("source_manifest.json", state.manifest);
1825
+ }
1826
+ // --- episode-plan ---------------------------------------------------------
1827
+ export async function stageEpisodePlan(ctx) {
1828
+ const { ws, state, options } = ctx;
1829
+ const sourceText = ensureSourceText(ctx);
1830
+ ctx.updateRunState({ status: "init_running", init_stage: "episode_plan" });
1831
+ ctx.emitProgress("episode-plan");
1414
1832
  let plan;
1415
1833
  try {
1416
1834
  plan = buildEpisodePlan(sourceText);
1417
1835
  }
1418
1836
  catch (exc) {
1419
- const e = exc;
1420
- throw initFailedReport(workspace, {
1837
+ stageFailure(options.workspace, exc, {
1421
1838
  title: "INIT FAILED: Episode planning failed",
1422
1839
  stage: "episode_plan",
1423
1840
  required: ["source.txt that can be split into episodes"],
1424
- received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1425
1841
  nextSteps: ["Inspect workspace/source.txt, fix the source file, and rerun init."],
1426
1842
  });
1427
1843
  }
1428
- updateRunState(workspace, { status: "init_running", init_stage: "episode_titles" });
1429
- const capabilities = providerCapabilities(providerName);
1844
+ state.episodePlan = plan;
1845
+ ws.writeJson("episode_plan.json", plan);
1846
+ }
1847
+ // --- episode-titles -------------------------------------------------------
1848
+ export async function stageEpisodeTitles(ctx) {
1849
+ const { state, options } = ctx;
1850
+ const sourceText = ensureSourceText(ctx);
1851
+ const plan = ensureEpisodePlan(ctx);
1852
+ // Provider capability gate (was inline in commandInitImpl before titles).
1853
+ // Checked twice — declared capabilities, then the constructed provider — but
1854
+ // both fail the same way, so share one report builder.
1855
+ const lacksExtraction = () => initFailedReport(options.workspace, {
1856
+ title: "INIT FAILED: provider lacks batch extraction",
1857
+ stage: "episode_titles",
1858
+ required: ["provider.extractBatch or provider.extractEpisode"],
1859
+ received: [`--provider ${options.providerName}`],
1860
+ nextSteps: ["Use --provider anthropic or --provider mock for direct init."],
1861
+ });
1862
+ const capabilities = providerCapabilities(options.providerName);
1430
1863
  if (capabilities && !capabilities.extractBatch && !capabilities.extractEpisode) {
1431
- throw initFailedReport(workspace, {
1432
- title: "INIT FAILED: provider lacks batch extraction",
1433
- stage: "episode_titles",
1434
- required: ["provider.extractBatch or provider.extractEpisode"],
1435
- received: [`--provider ${providerName}`],
1436
- nextSteps: ["Use --provider anthropic or --provider mock for direct init."],
1437
- });
1438
- }
1439
- let provider;
1440
- try {
1441
- provider = makeProvider(providerName, model);
1442
- }
1443
- catch (exc) {
1444
- if (exc instanceof CliError) {
1445
- updateRunState(workspace, {
1446
- status: "init_failed",
1447
- init_stage: "episode_titles",
1448
- last_error: { title: exc.title, received: exc.received, failed_at: checkpointTimestamp() },
1449
- });
1450
- }
1451
- throw exc;
1864
+ throw lacksExtraction();
1452
1865
  }
1866
+ const provider = ctx.provider();
1453
1867
  if (!provider.extractBatch && !provider.extractEpisode) {
1454
- throw initFailedReport(workspace, {
1455
- title: "INIT FAILED: provider lacks batch extraction",
1456
- stage: "episode_titles",
1457
- required: ["provider.extractBatch or provider.extractEpisode"],
1458
- received: [`--provider ${providerName}`],
1459
- nextSteps: ["Use --provider anthropic or --provider mock for direct init."],
1460
- });
1461
- }
1462
- updateRunState(workspace, { status: "init_running", init_stage: "episode_titles" });
1463
- emitInitProgress(workspace, "episode_titles");
1464
- const titleDegraded = await enrichEpisodeTitlesChunked(workspace, sourceText, plan, provider, concurrency);
1868
+ throw lacksExtraction();
1869
+ }
1870
+ ctx.updateRunState({ status: "init_running", init_stage: "episode_titles" });
1871
+ ctx.emitProgress("episode-titles");
1872
+ state.titleDegraded = await enrichEpisodeTitlesChunked(options.workspace, sourceText, plan, provider, options.concurrency);
1873
+ // Titles mutate the plan in place persist it so the artifact reflects them.
1874
+ ctx.ws.writeJson("episode_plan.json", plan);
1875
+ }
1876
+ // --- batch-plan -----------------------------------------------------------
1877
+ export async function stageBatchPlan(ctx) {
1878
+ const { ws, state, options } = ctx;
1879
+ const sourceText = ensureSourceText(ctx);
1880
+ const plan = ensureEpisodePlan(ctx);
1465
1881
  let batchPlan;
1466
1882
  try {
1467
1883
  batchPlan = buildBatchPlan(sourceText, plan, {
1468
- targetLines: batchTargetLines,
1469
- maxChars: batchMaxChars,
1470
- minLines: batchMinLines,
1471
- mode: batchMode,
1884
+ targetLines: options.batchTargetLines,
1885
+ maxChars: options.batchMaxChars,
1886
+ minLines: options.batchMinLines,
1887
+ mode: options.batchMode,
1472
1888
  });
1473
1889
  }
1474
1890
  catch (exc) {
1475
- const e = exc;
1476
- throw initFailedReport(workspace, {
1891
+ stageFailure(options.workspace, exc, {
1477
1892
  title: "INIT FAILED: Batch planning failed",
1478
1893
  stage: "batch_plan",
1479
1894
  required: ["episode_plan.json that can be split into natural paragraph batches"],
1480
- received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1481
1895
  nextSteps: ["Inspect workspace/source.txt and episode_plan.json, then rerun init."],
1482
1896
  });
1483
1897
  }
1484
- writeJson(path.join(dd, "source_manifest.json"), manifest);
1485
- writeJson(path.join(dd, "episode_plan.json"), plan);
1486
- writeJson(path.join(dd, "batch_plan.json"), batchPlan);
1487
- emitInitProgress(workspace, "batch_plan", "success");
1488
- const episodeResultsDir = path.join(dd, "episode_results");
1489
- const batchResultsDir = path.join(dd, "batch_results");
1490
- fs.mkdirSync(episodeResultsDir, { recursive: true });
1491
- fs.mkdirSync(batchResultsDir, { recursive: true });
1492
- // Non-destructive GC: drop result/meta/error/md files for units the current
1493
- // plan no longer references (e.g. the source shed an episode). Pure function
1494
- // of the plan — it never touches a unit the plan still references, and retires
1495
- // the legacy v3 index.json. There is no whole-directory reset any more.
1898
+ state.batchPlan = batchPlan;
1899
+ ws.writeJson("batch_plan.json", batchPlan);
1900
+ ctx.emitProgress("batch-plan", "success");
1901
+ }
1902
+ // --- batch-extract --------------------------------------------------------
1903
+ export async function stageBatchExtract(ctx) {
1904
+ const { ws, state, options } = ctx;
1905
+ const sourceText = ensureSourceText(ctx);
1906
+ const plan = ensureEpisodePlan(ctx);
1907
+ const batchPlan = ensureBatchPlan(ctx);
1908
+ const provider = ctx.provider();
1909
+ const episodeResultsDir = ws.mkdir("episode_results");
1910
+ const batchResultsDir = ws.mkdir("batch_results");
1911
+ // Non-destructive GC for units the current plan no longer references.
1496
1912
  gcOrphanUnits(episodeResultsDir, new Set(asList(plan["episodes"]).map((ep) => episodeResultKey(ep))));
1497
1913
  gcOrphanUnits(batchResultsDir, new Set(asList(batchPlan["batches"]).map((b) => batchResultKey(b))));
1498
- const batchesByEpisode = new Map();
1499
- for (const batch of asList(batchPlan["batches"])) {
1500
- const epNum = Number(batch["episode"]);
1501
- if (!batchesByEpisode.has(epNum))
1502
- batchesByEpisode.set(epNum, []);
1503
- batchesByEpisode.get(epNum).push(batch);
1504
- }
1505
- const totalEpisodes = asList(plan["episodes"]).length;
1506
- const totalBatches = asList(batchPlan["batches"]).length;
1507
- // Decide which batches to run. State (classifyBatch) is shared with `direct
1508
- // status` so the two never disagree: a "done" batch is reused, an "error"
1509
- // batch is sticky (skipped on a plain rerun so a content filter can't loop),
1510
- // a "pending" (never-run or corrupt) batch runs. The agent overrides with
1511
- // explicit selection.
1914
+ state.totalEpisodes = asList(plan["episodes"]).length;
1915
+ state.totalBatches = asList(batchPlan["batches"]).length;
1916
+ // Decide which batches to run (force / reuse-done / sticky-error / pending).
1512
1917
  const pending = [];
1513
1918
  let reusedBatchCount = 0;
1514
1919
  let stickyErrorCount = 0;
@@ -1516,7 +1921,7 @@ async function commandInitImpl(opts) {
1516
1921
  const epNum = Number(batch["episode"]);
1517
1922
  const key = batchResultKey(batch);
1518
1923
  const errorPath = batchErrorPath(batchResultsDir, batch);
1519
- const forced = runAll || selEpisodes.has(epNum) || selBatches.has(key) || (retryErrors && exists(errorPath));
1924
+ const forced = options.runAll || options.selEpisodes.has(epNum) || options.selBatches.has(key) || (options.retryErrors && exists(errorPath));
1520
1925
  if (forced) {
1521
1926
  const resultPath = batchResultPath(batchResultsDir, batch);
1522
1927
  if (exists(resultPath))
@@ -1532,74 +1937,98 @@ async function commandInitImpl(opts) {
1532
1937
  break;
1533
1938
  case "error":
1534
1939
  stickyErrorCount++;
1535
- break; // tried and failed; left until explicitly re-run
1536
- default: pending.push(batch); // never attempted (or corrupt → self-heal)
1940
+ break;
1941
+ default: pending.push(batch);
1537
1942
  }
1538
1943
  }
1539
- // Episodes whose batches were (re)run this round — their episode synopsis is
1540
- // recomputed; untouched episodes reuse their persisted `syn`. Summary failures
1541
- // accumulate here and are reported without ever blocking init.
1542
- const changedEpisodes = new Set(pending.map((b) => Number(b["episode"])));
1543
- const summaryDegradedEpisodes = [];
1544
- updateRunState(workspace, {
1944
+ state.pending = pending;
1945
+ state.reusedBatchCount = reusedBatchCount;
1946
+ state.stickyErrorCount = stickyErrorCount;
1947
+ state.changedEpisodes = new Set(pending.map((b) => Number(b["episode"])));
1948
+ ctx.updateRunState({
1545
1949
  status: "init_running",
1546
1950
  init_stage: "batch_extract",
1547
- batch_mode: batchMode,
1548
- batch_target_lines: batchTargetLines,
1549
- batch_max_chars: batchMaxChars,
1550
- batch_min_lines: batchMinLines,
1551
- episode_total: totalEpisodes,
1552
- batch_total: totalBatches,
1951
+ batch_mode: options.batchMode,
1952
+ batch_target_lines: options.batchTargetLines,
1953
+ batch_max_chars: options.batchMaxChars,
1954
+ batch_min_lines: options.batchMinLines,
1955
+ episode_total: state.totalEpisodes,
1956
+ batch_total: state.totalBatches,
1553
1957
  });
1554
- emitInitProgress(workspace, "batch_extract");
1555
- // Persist each batch the moment it finishes (inside the worker), NOT after the
1556
- // whole pool drains. Per-unit files have no shared index, so concurrent writes
1557
- // are safe — and batch_results/ fills up live, so a crash/kill mid-run keeps
1558
- // every batch already extracted (the resume model depends on this).
1559
- const outcomes = await pMapWithConcurrency(pending, concurrency, async (batch) => {
1958
+ ctx.emitProgress("batch-extract");
1959
+ const outcomes = await pMapWithConcurrency(pending, options.concurrency, async (batch) => {
1560
1960
  try {
1561
1961
  const result = await extractBatchWithRecovery(provider, sourceText, batch);
1562
1962
  persistBatchResult(batchResultsDir, batch, result);
1563
1963
  const errorPath = batchErrorPath(batchResultsDir, batch);
1564
1964
  if (exists(errorPath))
1565
1965
  deletePath(errorPath);
1566
- emitInitProgress(workspace, "batch_extract");
1966
+ ctx.emitProgress("batch-extract");
1567
1967
  return true;
1568
1968
  }
1569
1969
  catch (error) {
1570
1970
  writeBatchFailure(batchResultsDir, batch, error);
1571
- emitInitProgress(workspace, "batch_extract");
1971
+ ctx.emitProgress("batch-extract");
1572
1972
  return false;
1573
1973
  }
1574
1974
  });
1575
1975
  const freshOk = outcomes.filter((o) => o.ok && o.value === true).length;
1576
1976
  const freshFail = outcomes.filter((o) => o.ok && o.value === false).length;
1577
- const doneBatches = reusedBatchCount + freshOk;
1578
- const erroredBatches = stickyErrorCount + freshFail;
1579
- // Assemble every episode whose batches are all present on disk (reused +
1580
- // freshly extracted, read uniformly from disk). Incomplete episodes are
1581
- // reported; assembling them is left to an explicit re-run.
1977
+ state.doneBatches = reusedBatchCount + freshOk;
1978
+ state.erroredBatches = stickyErrorCount + freshFail;
1979
+ }
1980
+ // --- episode-merge --------------------------------------------------------
1981
+ export async function stageEpisodeMerge(ctx) {
1982
+ const { ws, state } = ctx;
1983
+ const sourceText = ensureSourceText(ctx);
1984
+ const plan = ensureEpisodePlan(ctx);
1985
+ const batchPlan = ensureBatchPlan(ctx);
1986
+ const episodeResultsDir = ws.mkdir("episode_results");
1987
+ const batchResultsDir = ws.mkdir("batch_results");
1988
+ // Recompute the bookkeeping a standalone `direct run episode-merge` needs when
1989
+ // batch-extract didn't run this invocation.
1990
+ if (state.totalEpisodes === 0)
1991
+ state.totalEpisodes = asList(plan["episodes"]).length;
1992
+ if (state.totalBatches === 0)
1993
+ state.totalBatches = asList(batchPlan["batches"]).length;
1994
+ const batchesByEpisode = new Map();
1995
+ for (const batch of asList(batchPlan["batches"])) {
1996
+ const epNum = Number(batch["episode"]);
1997
+ if (!batchesByEpisode.has(epNum))
1998
+ batchesByEpisode.set(epNum, []);
1999
+ batchesByEpisode.get(epNum).push(batch);
2000
+ }
2001
+ if (state.doneBatches === 0 && state.erroredBatches === 0) {
2002
+ let done = 0;
2003
+ let errored = 0;
2004
+ for (const batch of asList(batchPlan["batches"])) {
2005
+ const s = classifyBatch(batchResultsDir, batch);
2006
+ if (s === "done")
2007
+ done++;
2008
+ else if (s === "error")
2009
+ errored++;
2010
+ }
2011
+ state.doneBatches = done;
2012
+ state.erroredBatches = errored;
2013
+ }
1582
2014
  const results = [];
1583
2015
  const incompleteEpisodes = [];
1584
- updateRunState(workspace, {
2016
+ ctx.updateRunState({
1585
2017
  status: "init_running",
1586
2018
  init_stage: "episode_merge",
1587
- episode_total: totalEpisodes,
1588
- batch_total: totalBatches,
1589
- batch_completed: doneBatches,
1590
- batch_failed: erroredBatches,
2019
+ episode_total: state.totalEpisodes,
2020
+ batch_total: state.totalBatches,
2021
+ batch_completed: state.doneBatches,
2022
+ batch_failed: state.erroredBatches,
1591
2023
  });
1592
- emitInitProgress(workspace, "episode_merge");
2024
+ ctx.emitProgress("episode-merge");
1593
2025
  try {
1594
- // Assemble every complete episode (incomplete ones are reported and skipped).
1595
2026
  for (const episode of asList(plan["episodes"])) {
1596
2027
  const episodeNum = Number(episode["episode"]);
1597
2028
  const epBatches = batchesByEpisode.get(episodeNum) ?? [];
1598
2029
  const batchResults = epBatches.map((b) => readBatchResult(batchResultsDir, b)).filter((r) => r !== null);
1599
2030
  if (epBatches.length === 0 || batchResults.length !== epBatches.length) {
1600
2031
  incompleteEpisodes.push(episodeNum);
1601
- // Drop any stale episode_results/ep_NNN.json from a prior complete run so
1602
- // the derived artifact never claims an episode is complete when it isn't.
1603
2032
  const stale = episodeResultPath(episodeResultsDir, episode);
1604
2033
  if (exists(stale))
1605
2034
  deletePath(stale);
@@ -1607,194 +2036,180 @@ async function commandInitImpl(opts) {
1607
2036
  }
1608
2037
  const merged = mergeBatchResultsForEpisode(episode, batchResults);
1609
2038
  validateEpisodeExtractionQuality(sourceText, episode, merged);
1610
- results.push(merged); // complete episodes only; carries `episode` for the write below
1611
- }
1612
- emitInitProgress(workspace, "episode_merge", incompleteEpisodes.length > 0 ? "warning" : "success");
1613
- // Episode-level synopsis (reduce-1): resolve each result's synopsis in place
1614
- // (passthrough / reuse / skip / concurrent LLM stitch). Reads prior `syn`
1615
- // BEFORE the persist loop below overwrites ep_NNN.json.
1616
- updateRunState(workspace, { status: "init_running", init_stage: "episode_synopsis" });
1617
- emitInitProgress(workspace, "episode_synopsis");
1618
- summaryDegradedEpisodes.push(...await resolveEpisodeSynopses(provider, results, {
1619
- resultsDir: episodeResultsDir,
1620
- resummarize,
1621
- skipSummary,
1622
- changedEpisodes,
1623
- concurrency,
1624
- }));
1625
- // Persist every assembled episode (synopsis now resolved for all of them).
1626
- // merged carries `episode`, so it doubles as the path key.
1627
- for (const merged of results) {
1628
- writeJson(episodeResultPath(episodeResultsDir, merged), compactEpisodeResult(merged));
1629
- const errPath = episodeErrorPath(episodeResultsDir, merged);
1630
- if (exists(errPath))
1631
- deletePath(errPath);
2039
+ results.push(merged);
1632
2040
  }
1633
- emitInitProgress(workspace, "episode_synopsis", summaryDegradedEpisodes.length > 0 ? "warning" : "success");
1634
2041
  }
1635
2042
  catch (exc) {
1636
- const e = exc;
1637
- throw initFailedReport(workspace, {
2043
+ stageFailure(ctx.options.workspace, exc, {
1638
2044
  title: "INIT FAILED: Episode merge failed",
1639
2045
  stage: "episode_merge",
1640
2046
  required: ["batch_results/*.json that can merge into episode_results/*.json"],
1641
- received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1642
2047
  nextSteps: ["Rerun init; completed batches are reused and episode merge will retry."],
1643
2048
  });
1644
2049
  }
1645
- results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
1646
- incompleteEpisodes.sort((a, b) => a - b);
1647
- if (incompleteEpisodes.length > 0) {
1648
- updateRunState(workspace, {
2050
+ state.mergedResults = results;
2051
+ state.incompleteEpisodes = incompleteEpisodes;
2052
+ ctx.emitProgress("episode-merge", incompleteEpisodes.length > 0 ? "warning" : "success");
2053
+ }
2054
+ // --- episode-synopsis -----------------------------------------------------
2055
+ export async function stageEpisodeSynopsis(ctx) {
2056
+ const { ws, state, options } = ctx;
2057
+ const provider = ctx.provider();
2058
+ const episodeResultsDir = ws.mkdir("episode_results");
2059
+ const results = ensureMergedResults(ctx);
2060
+ ctx.updateRunState({ status: "init_running", init_stage: "episode_synopsis" });
2061
+ ctx.emitProgress("episode-synopsis");
2062
+ state.summaryDegradedEpisodes.push(...await resolveEpisodeSynopses(provider, results, {
2063
+ resultsDir: episodeResultsDir,
2064
+ resummarize: options.resummarize,
2065
+ skipSummary: options.skipSummary,
2066
+ changedEpisodes: state.changedEpisodes,
2067
+ concurrency: options.concurrency,
2068
+ }));
2069
+ for (const merged of results) {
2070
+ writeJson(episodeResultPath(episodeResultsDir, merged), compactEpisodeResult(merged));
2071
+ const errPath = episodeErrorPath(episodeResultsDir, merged);
2072
+ if (exists(errPath))
2073
+ deletePath(errPath);
2074
+ }
2075
+ ctx.emitProgress("episode-synopsis", state.summaryDegradedEpisodes.length > 0 ? "warning" : "success");
2076
+ // Incomplete episodes are a resumable, non-error stop. The full-pipeline
2077
+ // runner converts this into the INIT INCOMPLETE report + EXIT_RUNTIME; a
2078
+ // standalone `direct run episode-synopsis` surfaces it the same way.
2079
+ if (state.incompleteEpisodes.length > 0) {
2080
+ state.incompleteEpisodes.sort((a, b) => a - b);
2081
+ ctx.updateRunState({
1649
2082
  status: "init_incomplete",
1650
2083
  init_stage: "batch_extract",
1651
- episode_total: totalEpisodes,
1652
- episode_completed: results.length,
1653
- incomplete_episodes: incompleteEpisodes,
1654
- batch_total: totalBatches,
1655
- batch_completed: doneBatches,
1656
- batch_failed: erroredBatches,
2084
+ episode_total: state.totalEpisodes,
2085
+ episode_completed: state.mergedResults.length,
2086
+ incomplete_episodes: state.incompleteEpisodes,
2087
+ batch_total: state.totalBatches,
2088
+ batch_completed: state.doneBatches,
2089
+ batch_failed: state.erroredBatches,
1657
2090
  last_error: { title: "INIT INCOMPLETE: Some episodes have unextracted batches", failed_at: checkpointTimestamp() },
1658
2091
  });
1659
- emitInitProgress(workspace, "batch_extract", "error");
1660
- const report = {
1661
- title: "INIT INCOMPLETE: Some episodes have unextracted batches",
1662
- result: [
1663
- `episodes: ${results.length}/${totalEpisodes} complete`,
1664
- `incomplete episodes: ${incompleteEpisodes.join(", ")}`,
1665
- `content: ${doneBatches}/${totalBatches} done, ${erroredBatches} error`,
1666
- ...(titleDegraded.length > 0 ? [`titles degraded (deterministic): ${titleDegraded.join(", ")}`] : []),
1667
- `provider: ${providerName}`,
1668
- ],
1669
- artifacts: [
1670
- path.join(workspace, "source.txt"),
1671
- path.join(dd, "episode_plan.json"),
1672
- path.join(dd, "batch_plan.json"),
1673
- batchResultsDir,
1674
- episodeResultsDir,
1675
- path.join(dd, "run_state.json"),
1676
- ],
1677
- next: [
1678
- "Run `direct status` to see episode progress.",
1679
- "Re-run one episode with `direct init --episodes <n>`, or all errors with `--retry-errors`.",
1680
- "Errored units are left untouched on a plain rerun; select them to retry.",
1681
- ],
1682
- };
1683
- return [report, EXIT_RUNTIME];
2092
+ ctx.emitProgress("batch-extract", "error");
2093
+ throw new StageIncomplete();
1684
2094
  }
1685
- updateRunState(workspace, {
2095
+ state.mergedResults.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
2096
+ }
2097
+ // --- script-merge ---------------------------------------------------------
2098
+ export async function stageScriptMerge(ctx) {
2099
+ const { ws, state, options } = ctx;
2100
+ const results = ensureMergedResults(ctx);
2101
+ ctx.updateRunState({
1686
2102
  status: "init_running",
1687
2103
  init_stage: "script_merge",
1688
- episode_total: totalEpisodes,
2104
+ episode_total: state.totalEpisodes || asList((ensureEpisodePlan(ctx))["episodes"]).length,
1689
2105
  episode_completed: results.length,
1690
2106
  incomplete_episodes: [],
1691
- batch_total: totalBatches,
1692
- batch_completed: doneBatches,
1693
- batch_failed: erroredBatches,
2107
+ batch_total: state.totalBatches,
2108
+ batch_completed: state.doneBatches,
2109
+ batch_failed: state.erroredBatches,
1694
2110
  last_error: null,
1695
2111
  });
1696
- let script;
1697
- const assetNormalizationPath = path.join(dd, "asset_normalization.json");
1698
- const assetMetadataPath = path.join(dd, "asset_metadata.json");
1699
- let assetNormalization = null;
2112
+ ctx.emitProgress("script-merge");
1700
2113
  try {
1701
- emitInitProgress(workspace, "script_merge");
1702
- const merged = mergeEpisodeResultsWithReport(results, strOf(info["projectName"]) || path.basename(source, path.extname(source)));
1703
- script = merged.script;
1704
- assetNormalization = merged.assetNormalization;
1705
- writeJson(assetNormalizationPath, assetNormalization);
2114
+ const merged = mergeEpisodeResultsWithReport(results, ensureProjectName(ctx));
2115
+ state.script = merged.script;
2116
+ state.assetNormalization = merged.assetNormalization;
2117
+ ws.writeJson("asset_normalization.json", state.assetNormalization);
1706
2118
  }
1707
2119
  catch (exc) {
1708
- const e = exc;
1709
- throw initFailedReport(workspace, {
2120
+ stageFailure(options.workspace, exc, {
1710
2121
  title: "INIT FAILED: Merge failed",
1711
2122
  stage: "script_merge",
1712
2123
  required: ["complete episode_results/*.json"],
1713
- received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1714
2124
  nextSteps: ["Rerun init; completed episode extraction checkpoints will be reused and merge will retry."],
1715
2125
  updates: { episode_completed: results.length },
1716
2126
  });
1717
2127
  }
2128
+ }
2129
+ // --- asset-curation -------------------------------------------------------
2130
+ export async function stageAssetCuration(ctx) {
2131
+ const { ws, state, options } = ctx;
2132
+ const script = ensureScript(ctx);
1718
2133
  try {
1719
- updateRunState(workspace, { status: "init_running", init_stage: "asset_curation" });
1720
- emitInitProgress(workspace, "asset_curation");
1721
- // Location merge is deterministic-only: actor/prop pruning runs from scene
1722
- // counts and every location is kept. The previous provider location-merge
1723
- // call was removed (single large streaming request kept dropping the gateway
1724
- // connection); duplicate locations can be merged later if needed.
2134
+ ctx.updateRunState({ status: "init_running", init_stage: "asset_curation" });
2135
+ ctx.emitProgress("asset-curation");
1725
2136
  const curation = curateScriptAssets(script);
1726
- writeJson(path.join(dd, "asset_curation.json"), curation);
2137
+ ws.writeJson("asset_curation.json", curation);
1727
2138
  }
1728
2139
  catch (exc) {
1729
- // curateScriptAssets is deterministic (no provider call) — a failure here is
1730
- // a malformed assembled script, not a gateway/provider problem.
1731
- const e = exc;
1732
- throw initFailedReport(workspace, {
2140
+ stageFailure(options.workspace, exc, {
1733
2141
  title: "INIT FAILED: Asset curation failed",
1734
2142
  stage: "asset_curation",
1735
2143
  required: ["a well-formed assembled script for deterministic asset curation"],
1736
- received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1737
2144
  nextSteps: ["Rerun init; completed extraction checkpoints will be reused."],
1738
- updates: { episode_completed: results.length },
2145
+ updates: { episode_completed: state.mergedResults.length },
1739
2146
  });
1740
2147
  }
1741
- emitInitProgress(workspace, "asset_curation", "success");
1742
- if (skipMetadata) {
1743
- updateRunState(workspace, { status: "init_running", init_stage: "metadata_extract", metadata_skipped: true });
2148
+ ctx.emitProgress("asset-curation", "success");
2149
+ }
2150
+ // --- metadata -------------------------------------------------------------
2151
+ export async function stageMetadata(ctx) {
2152
+ const { ws, state, options } = ctx;
2153
+ const script = ensureScript(ctx);
2154
+ const sourceText = ensureSourceText(ctx);
2155
+ const assetMetadataPath = ws.pathOf("asset_metadata.json");
2156
+ if (options.skipMetadata) {
2157
+ ctx.updateRunState({ status: "init_running", init_stage: "metadata_extract", metadata_skipped: true });
1744
2158
  if (exists(assetMetadataPath))
1745
2159
  deletePath(assetMetadataPath);
1746
- emitInitProgress(workspace, "metadata_extract", "skipped");
2160
+ ctx.emitProgress("metadata", "skipped");
2161
+ return;
1747
2162
  }
1748
- else {
1749
- try {
1750
- updateRunState(workspace, { status: "init_running", init_stage: "metadata_extract", metadata_skipped: false });
1751
- emitInitProgress(workspace, "metadata_extract");
1752
- let metadata = provider.extractMetadata ? await provider.extractMetadata(sourceText, script) : {};
1753
- if (!isDict(metadata))
1754
- metadata = {};
1755
- writeJson(assetMetadataPath, metadata);
1756
- applyMetadataToScript(script, metadata);
1757
- }
1758
- catch (exc) {
1759
- if (exc instanceof CliError) {
1760
- throw initFailedReport(workspace, {
1761
- title: exc.title,
1762
- stage: "metadata_extract",
1763
- exitCode: exc.exitCode,
1764
- errorCode: exc.errorCode,
1765
- required: exc.required.length > 0 ? exc.required : ["metadata JSON matching final script contract"],
1766
- received: exc.received.length > 0 ? exc.received : [String(exc.message).slice(0, 160)],
1767
- nextSteps: exc.nextSteps.length > 0 ? exc.nextSteps : ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
1768
- updates: { episode_completed: results.length },
1769
- });
1770
- }
1771
- const e = exc;
1772
- throw initFailedReport(workspace, {
1773
- title: "INIT FAILED: Metadata extraction failed",
2163
+ const provider = ctx.provider();
2164
+ try {
2165
+ ctx.updateRunState({ status: "init_running", init_stage: "metadata_extract", metadata_skipped: false });
2166
+ ctx.emitProgress("metadata");
2167
+ let metadata = provider.extractMetadata ? await provider.extractMetadata(sourceText, script) : {};
2168
+ if (!isDict(metadata))
2169
+ metadata = {};
2170
+ ws.writeJson("asset_metadata.json", metadata);
2171
+ applyMetadataToScript(script, metadata);
2172
+ }
2173
+ catch (exc) {
2174
+ if (exc instanceof CliError) {
2175
+ throw initFailedReport(options.workspace, {
2176
+ title: exc.title,
1774
2177
  stage: "metadata_extract",
1775
- required: ["provider metadata for worldview, role_type, and asset descriptions"],
1776
- received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1777
- nextSteps: ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
1778
- updates: { episode_completed: results.length },
2178
+ exitCode: exc.exitCode,
2179
+ errorCode: exc.errorCode,
2180
+ required: exc.required.length > 0 ? exc.required : ["metadata JSON matching final script contract"],
2181
+ received: exc.received.length > 0 ? exc.received : [String(exc.message).slice(0, 160)],
2182
+ nextSteps: exc.nextSteps.length > 0 ? exc.nextSteps : ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
2183
+ updates: { episode_completed: state.mergedResults.length },
1779
2184
  });
1780
2185
  }
1781
- emitInitProgress(workspace, "metadata_extract", "success");
2186
+ stageFailure(options.workspace, exc, {
2187
+ title: "INIT FAILED: Metadata extraction failed",
2188
+ stage: "metadata_extract",
2189
+ required: ["provider metadata for worldview, role_type, and asset descriptions"],
2190
+ nextSteps: ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
2191
+ updates: { episode_completed: state.mergedResults.length },
2192
+ });
1782
2193
  }
1783
- // Whole-script overview (reduce-2). Soft: a non-essential concept enhancement,
1784
- // the opposite of batch extraction's hard-fail policy — failures degrade the
1785
- // overview (recorded as summary_degraded + a sanitized reason) and NEVER block init.
2194
+ ctx.emitProgress("metadata", "success");
2195
+ }
2196
+ // --- script-synopsis ------------------------------------------------------
2197
+ export async function stageScriptSynopsis(ctx) {
2198
+ const { ws, state, options } = ctx;
2199
+ const script = ensureScript(ctx);
2200
+ const provider = ctx.provider();
1786
2201
  let summaryFullDegraded = false;
1787
2202
  let summaryDegradeReason = "";
1788
2203
  try {
1789
- updateRunState(workspace, { status: "init_running", init_stage: "script_synopsis" });
1790
- emitInitProgress(workspace, "script_synopsis");
2204
+ ctx.updateRunState({ status: "init_running", init_stage: "script_synopsis" });
2205
+ ctx.emitProgress("script-synopsis");
1791
2206
  const summary = await buildScriptSynopsis(provider, script, {
1792
- dd,
1793
- thresholdChars: summaryThresholdChars,
2207
+ dd: ws.dir,
2208
+ thresholdChars: options.summaryThresholdChars,
1794
2209
  groupSize: DEFAULT_SUMMARY_GROUP_SIZE,
1795
- resummarize,
1796
- skipSummary,
1797
- concurrency,
2210
+ resummarize: options.resummarize,
2211
+ skipSummary: options.skipSummary,
2212
+ concurrency: options.concurrency,
1798
2213
  });
1799
2214
  summaryFullDegraded = summary.degraded;
1800
2215
  summaryDegradeReason = summary.reason ?? "";
@@ -1803,116 +2218,44 @@ async function commandInitImpl(opts) {
1803
2218
  summaryFullDegraded = true;
1804
2219
  summaryDegradeReason = providerErrorSummary(exc);
1805
2220
  }
1806
- summaryDegradedEpisodes.sort((a, b) => a - b); // concurrent stitch pushes out of order
1807
- const summaryDegraded = summaryFullDegraded || summaryDegradedEpisodes.length > 0;
1808
- const scriptPath = path.join(dd, "script.initial.json");
1809
- writeJson(scriptPath, script);
1810
- updateRunState(workspace, {
1811
- summary_skipped: skipSummary,
2221
+ state.summaryFullDegraded = summaryFullDegraded;
2222
+ state.summaryDegradeReason = summaryDegradeReason;
2223
+ state.summaryDegradedEpisodes.sort((a, b) => a - b);
2224
+ const summaryDegraded = summaryFullDegraded || state.summaryDegradedEpisodes.length > 0;
2225
+ // Persist script.initial.json now that the overview fields are assigned — the
2226
+ // same write point as the god-command (right after the synopsis stage).
2227
+ ws.writeJson("script.initial.json", script);
2228
+ ctx.updateRunState({
2229
+ summary_skipped: options.skipSummary,
1812
2230
  summary_degraded: summaryDegraded,
1813
- summary_degraded_episodes: summaryDegradedEpisodes,
2231
+ summary_degraded_episodes: state.summaryDegradedEpisodes,
1814
2232
  summary_degraded_reason: summaryDegradeReason || null,
1815
2233
  });
1816
- emitInitProgress(workspace, "script_synopsis", summaryDegraded ? "warning" : skipSummary ? "skipped" : "success");
1817
- updateRunState(workspace, { status: "init_running", init_stage: "validate" });
1818
- emitInitProgress(workspace, "validate");
2234
+ ctx.emitProgress("script-synopsis", summaryDegraded ? "warning" : options.skipSummary ? "skipped" : "success");
2235
+ }
2236
+ // --- validate -------------------------------------------------------------
2237
+ export async function stageValidate(ctx) {
2238
+ const { ws, state, options } = ctx;
2239
+ ensureScript(ctx); // ensures script.initial.json exists on disk for validateScript
2240
+ const scriptPath = ws.pathOf("script.initial.json");
2241
+ ctx.updateRunState({ status: "init_running", init_stage: "validate" });
2242
+ ctx.emitProgress("validate");
1819
2243
  let validation;
1820
2244
  try {
1821
- validation = validateScript(workspace, scriptPath);
2245
+ validation = validateScript(options.workspace, scriptPath);
1822
2246
  }
1823
2247
  catch (exc) {
1824
- const e = exc;
1825
- throw initFailedReport(workspace, {
2248
+ stageFailure(options.workspace, exc, {
1826
2249
  title: "INIT FAILED: Validation failed",
1827
2250
  stage: "validate",
1828
2251
  required: ["script.initial.json that can be validated"],
1829
- received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1830
2252
  nextSteps: ["Rerun init to retry validation, or inspect script.initial.json if the failure persists."],
1831
2253
  updates: { script_path: scriptPath },
1832
2254
  });
1833
2255
  }
2256
+ state.validation = validation;
1834
2257
  const passed = Boolean(validation["passed"]);
1835
- const status = passed ? "ready_for_agent" : "needs_agent_repair";
1836
- updateRunState(workspace, {
1837
- status,
1838
- command: "direct init",
1839
- init_stage: "complete",
1840
- provider: providerName,
1841
- model,
1842
- concurrency,
1843
- batch_mode: batchMode,
1844
- batch_target_lines: batchTargetLines,
1845
- batch_max_chars: batchMaxChars,
1846
- batch_min_lines: batchMinLines,
1847
- source_path: path.resolve(source),
1848
- script_path: scriptPath,
1849
- validation_path: path.join(dd, "validation.json"),
1850
- episode_total: totalEpisodes,
1851
- episode_completed: results.length,
1852
- incomplete_episodes: [],
1853
- batch_total: totalBatches,
1854
- batch_completed: doneBatches,
1855
- batch_reused: reusedBatchCount,
1856
- batch_failed: erroredBatches,
1857
- summary_threshold_chars: summaryThresholdChars,
1858
- summary_skipped: skipSummary,
1859
- summary_degraded: summaryDegraded,
1860
- summary_degraded_episodes: summaryDegradedEpisodes,
1861
- summary_degraded_reason: summaryDegradeReason || null,
1862
- metadata_skipped: skipMetadata,
1863
- last_error: null,
1864
- });
1865
- emitInitProgress(workspace, "validate", passed ? "success" : "warning");
1866
- const title = passed
1867
- ? "INIT COMPLETE: Initial script ready"
1868
- : "INIT NEEDS AGENT: Initial script written with repair issues";
1869
- const stats = validation["stats"] ?? {};
1870
- const summaryLine = formatSummaryStatusLine(skipSummary, summaryDegraded, summaryDegradedEpisodes);
1871
- const normalizationSummary = assetNormalization?.summary ?? {
1872
- asset_merges: 0,
1873
- location_states_from_names: 0,
1874
- location_states_from_time: 0,
1875
- review_items: 0,
1876
- };
1877
- const report = {
1878
- title,
1879
- result: [
1880
- `episodes: ${stats["episodes"] ?? 0}`,
1881
- `scenes: ${stats["scenes"] ?? 0}`,
1882
- `actions: ${stats["actions"] ?? 0}`,
1883
- `validation: ${passed ? "passed" : "needs repair"}`,
1884
- `provider: ${providerName}`,
1885
- `episodes: ${results.length}/${totalEpisodes} complete`,
1886
- `content: ${doneBatches}/${totalBatches} done`,
1887
- `normalization: merges=${normalizationSummary.asset_merges}, states=${normalizationSummary.location_states_from_names + normalizationSummary.location_states_from_time}, review=${normalizationSummary.review_items}`,
1888
- ...(skipMetadata ? ["metadata: skipped"] : []),
1889
- ...(titleDegraded.length > 0 ? [`titles degraded (deterministic): ${titleDegraded.join(", ")}`] : []),
1890
- ...(summaryLine ? [summaryLine] : []),
1891
- ],
1892
- artifacts: [
1893
- path.join(workspace, "source.txt"),
1894
- path.join(dd, "source_manifest.json"),
1895
- path.join(dd, "episode_plan.json"),
1896
- path.join(dd, "batch_plan.json"),
1897
- batchResultsDir,
1898
- episodeResultsDir,
1899
- assetNormalizationPath,
1900
- path.join(dd, "asset_curation.json"),
1901
- ...(skipMetadata ? [] : [assetMetadataPath]),
1902
- path.join(dd, "synopsis.json"),
1903
- scriptPath,
1904
- path.join(dd, "validation.json"),
1905
- path.join(dd, "run_state.json"),
1906
- ],
1907
- issues: summarizeIssues(asList(validation["issues"])),
1908
- next: providerName === "mock"
1909
- ? [
1910
- "Run `direct review` (add --episode <n> for source↔extract); edit with --draft if needed; then validate/export.",
1911
- "Do not export mock-provider results for delivery.",
1912
- ]
1913
- : ["Run `direct review` (add --episode <n> for source↔extract); edit with --draft if needed; then validate/export."],
1914
- };
1915
- return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
2258
+ ctx.emitProgress("validate", passed ? "success" : "warning");
1916
2259
  }
1917
2260
  // ---------------------------------------------------------------------------
1918
2261
  // command_status — the agent-facing state table, derived entirely from disk: a