@lingjingai/scriptctl 0.28.3 → 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 (117) hide show
  1. package/changes/0.28.4.md +13 -0
  2. package/changes/0.29.0.md +17 -0
  3. package/dist/cli.js +25 -3
  4. package/dist/cli.js.map +1 -1
  5. package/dist/common.d.ts +1 -0
  6. package/dist/common.js +3 -0
  7. package/dist/common.js.map +1 -1
  8. package/dist/domain/direct/runner.d.ts +23 -0
  9. package/dist/domain/direct/runner.js +72 -0
  10. package/dist/domain/direct/runner.js.map +1 -0
  11. package/dist/domain/direct/stage.d.ts +86 -0
  12. package/dist/domain/direct/stage.js +131 -0
  13. package/dist/domain/direct/stage.js.map +1 -0
  14. package/dist/domain/direct/stages/asset-curation.d.ts +2 -0
  15. package/dist/domain/direct/stages/asset-curation.js +9 -0
  16. package/dist/domain/direct/stages/asset-curation.js.map +1 -0
  17. package/dist/domain/direct/stages/batch-extract.d.ts +2 -0
  18. package/dist/domain/direct/stages/batch-extract.js +12 -0
  19. package/dist/domain/direct/stages/batch-extract.js.map +1 -0
  20. package/dist/domain/direct/stages/batch-plan.d.ts +2 -0
  21. package/dist/domain/direct/stages/batch-plan.js +9 -0
  22. package/dist/domain/direct/stages/batch-plan.js.map +1 -0
  23. package/dist/domain/direct/stages/episode-merge.d.ts +2 -0
  24. package/dist/domain/direct/stages/episode-merge.js +11 -0
  25. package/dist/domain/direct/stages/episode-merge.js.map +1 -0
  26. package/dist/domain/direct/stages/episode-plan.d.ts +2 -0
  27. package/dist/domain/direct/stages/episode-plan.js +9 -0
  28. package/dist/domain/direct/stages/episode-plan.js.map +1 -0
  29. package/dist/domain/direct/stages/episode-synopsis.d.ts +2 -0
  30. package/dist/domain/direct/stages/episode-synopsis.js +12 -0
  31. package/dist/domain/direct/stages/episode-synopsis.js.map +1 -0
  32. package/dist/domain/direct/stages/episode-titles.d.ts +2 -0
  33. package/dist/domain/direct/stages/episode-titles.js +12 -0
  34. package/dist/domain/direct/stages/episode-titles.js.map +1 -0
  35. package/dist/domain/direct/stages/index.d.ts +17 -0
  36. package/dist/domain/direct/stages/index.js +37 -0
  37. package/dist/domain/direct/stages/index.js.map +1 -0
  38. package/dist/domain/direct/stages/metadata.d.ts +2 -0
  39. package/dist/domain/direct/stages/metadata.js +11 -0
  40. package/dist/domain/direct/stages/metadata.js.map +1 -0
  41. package/dist/domain/direct/stages/script-merge.d.ts +2 -0
  42. package/dist/domain/direct/stages/script-merge.js +15 -0
  43. package/dist/domain/direct/stages/script-merge.js.map +1 -0
  44. package/dist/domain/direct/stages/script-synopsis.d.ts +2 -0
  45. package/dist/domain/direct/stages/script-synopsis.js +12 -0
  46. package/dist/domain/direct/stages/script-synopsis.js.map +1 -0
  47. package/dist/domain/direct/stages/source-prepare.d.ts +2 -0
  48. package/dist/domain/direct/stages/source-prepare.js +9 -0
  49. package/dist/domain/direct/stages/source-prepare.js.map +1 -0
  50. package/dist/domain/direct/stages/validate.d.ts +2 -0
  51. package/dist/domain/direct/stages/validate.js +10 -0
  52. package/dist/domain/direct/stages/validate.js.map +1 -0
  53. package/dist/domain/direct-core.js +15 -1
  54. package/dist/domain/direct-core.js.map +1 -1
  55. package/dist/help-text.js +1 -1
  56. package/dist/infra/local-script-output-store.d.ts +2 -1
  57. package/dist/infra/local-script-output-store.js +11 -0
  58. package/dist/infra/local-script-output-store.js.map +1 -1
  59. package/dist/infra/providers.js +13 -4
  60. package/dist/infra/providers.js.map +1 -1
  61. package/dist/infra/script-output-api.d.ts +27 -4
  62. package/dist/infra/script-output-api.js +130 -106
  63. package/dist/infra/script-output-api.js.map +1 -1
  64. package/dist/infra/script-output-store.d.ts +24 -0
  65. package/dist/infra/script-output-store.js.map +1 -1
  66. package/dist/usecases/direct.d.ts +14 -0
  67. package/dist/usecases/direct.js +725 -360
  68. package/dist/usecases/direct.js.map +1 -1
  69. package/dist/usecases/parse.js +2 -2
  70. package/dist/usecases/parse.js.map +1 -1
  71. package/dist/usecases/script/actions.js.map +1 -1
  72. package/dist/usecases/script/actor.js.map +1 -1
  73. package/dist/usecases/script/actors.js.map +1 -1
  74. package/dist/usecases/script/add-actor.js.map +1 -1
  75. package/dist/usecases/script/add-episode.js.map +1 -1
  76. package/dist/usecases/script/add-location.js.map +1 -1
  77. package/dist/usecases/script/add-prop.js.map +1 -1
  78. package/dist/usecases/script/add-speaker.js.map +1 -1
  79. package/dist/usecases/script/alias.js.map +1 -1
  80. package/dist/usecases/script/assets.js.map +1 -1
  81. package/dist/usecases/script/context.js.map +1 -1
  82. package/dist/usecases/script/create.js.map +1 -1
  83. package/dist/usecases/script/delete.js.map +1 -1
  84. package/dist/usecases/script/describe.js.map +1 -1
  85. package/dist/usecases/script/dialogue.js.map +1 -1
  86. package/dist/usecases/script/do.js.map +1 -1
  87. package/dist/usecases/script/episodes.js.map +1 -1
  88. package/dist/usecases/script/export.js.map +1 -1
  89. package/dist/usecases/script/insert.js.map +1 -1
  90. package/dist/usecases/script/issues.js.map +1 -1
  91. package/dist/usecases/script/lib.js.map +1 -1
  92. package/dist/usecases/script/locations.js.map +1 -1
  93. package/dist/usecases/script/merge.js.map +1 -1
  94. package/dist/usecases/script/move.js.map +1 -1
  95. package/dist/usecases/script/overlap.js.map +1 -1
  96. package/dist/usecases/script/props.js.map +1 -1
  97. package/dist/usecases/script/refs.js.map +1 -1
  98. package/dist/usecases/script/rename.js.map +1 -1
  99. package/dist/usecases/script/replace.js.map +1 -1
  100. package/dist/usecases/script/role.js.map +1 -1
  101. package/dist/usecases/script/scenes.js.map +1 -1
  102. package/dist/usecases/script/script-patch.js.map +1 -1
  103. package/dist/usecases/script/script-validate.js.map +1 -1
  104. package/dist/usecases/script/session.d.ts +1 -0
  105. package/dist/usecases/script/session.js +15 -4
  106. package/dist/usecases/script/session.js.map +1 -1
  107. package/dist/usecases/script/speakers.js.map +1 -1
  108. package/dist/usecases/script/split.js.map +1 -1
  109. package/dist/usecases/script/state-add.js.map +1 -1
  110. package/dist/usecases/script/state-change.js.map +1 -1
  111. package/dist/usecases/script/state-delete.js.map +1 -1
  112. package/dist/usecases/script/state-rename.js.map +1 -1
  113. package/dist/usecases/script/summary.js.map +1 -1
  114. package/dist/usecases/script/transition.js.map +1 -1
  115. package/dist/usecases/script/type.js.map +1 -1
  116. package/dist/usecases/script/worldview.js.map +1 -1
  117. package/package.json +2 -1
@@ -1,15 +1,37 @@
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 "";
11
19
  return String(v);
12
20
  }
21
+ function legacyDirectInitStage(stage) {
22
+ if (stage === "episode_synopsis")
23
+ return "episode_merge";
24
+ if (stage === "script_synopsis")
25
+ return "summary";
26
+ return stage;
27
+ }
28
+ function runStateDirectStage(state) {
29
+ const initStage = strOf(state["init_stage"]);
30
+ const directStage = strOf(state["direct_stage"]);
31
+ if (directStage && legacyDirectInitStage(directStage) === initStage)
32
+ return directStage;
33
+ return initStage;
34
+ }
13
35
  function isDict(v) {
14
36
  return typeof v === "object" && v !== null && !Array.isArray(v);
15
37
  }
@@ -69,6 +91,14 @@ export function updateRunState(workspace, updates) {
69
91
  state = {};
70
92
  }
71
93
  }
94
+ const directStage = strOf(updates["direct_stage"]);
95
+ const initStage = strOf(updates["init_stage"]);
96
+ if (directStage)
97
+ updates["init_stage"] = legacyDirectInitStage(directStage);
98
+ else if (initStage) {
99
+ updates["direct_stage"] = initStage;
100
+ updates["init_stage"] = legacyDirectInitStage(initStage);
101
+ }
72
102
  Object.assign(state, updates);
73
103
  state["updated_at"] = checkpointTimestamp();
74
104
  writeJson(p, state);
@@ -549,7 +579,7 @@ function emitInitProgress(workspace, stageName, fallbackStatus = "running") {
549
579
  });
550
580
  }
551
581
  function emitFailedInitProgress(workspace) {
552
- const stage = strOf(readRunState(workspace)["init_stage"]);
582
+ const stage = runStateDirectStage(readRunState(workspace));
553
583
  if (isDirectStageName(stage))
554
584
  emitInitProgress(workspace, stage, "error");
555
585
  }
@@ -562,7 +592,7 @@ function buildDirectStatusSnapshot(workspace) {
562
592
  const episodes = asList(episodePlan["episodes"]);
563
593
  const batches = asList(batchPlan["batches"]);
564
594
  const state = readRunState(workspace);
565
- const rawRunningStage = strOf(state["init_stage"]);
595
+ const rawRunningStage = runStateDirectStage(state);
566
596
  const runningStage = rawRunningStage && rawRunningStage !== "complete" && isDirectStageName(rawRunningStage) ? rawRunningStage : null;
567
597
  const runStatus = strOf(state["status"]);
568
598
  const failedStage = runStatus === "init_failed" ? runningStage : null;
@@ -972,6 +1002,21 @@ function initFailedReport(workspace, opts) {
972
1002
  nextSteps: opts.nextSteps,
973
1003
  });
974
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
+ }
975
1020
  // ---------------------------------------------------------------------------
976
1021
  // Concurrency limiter (mirrors ThreadPoolExecutor)
977
1022
  // ---------------------------------------------------------------------------
@@ -1281,7 +1326,11 @@ export async function commandInit(opts) {
1281
1326
  finishInitProgress();
1282
1327
  }
1283
1328
  }
1284
- 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) {
1285
1334
  const sourcePathArg = strOf(opts["source_path"]);
1286
1335
  const source = sourcePathArg.startsWith("~")
1287
1336
  ? path.join(process.env.HOME ?? "", sourcePathArg.slice(1))
@@ -1305,7 +1354,7 @@ async function commandInitImpl(opts) {
1305
1354
  const skipMetadata = Boolean(opts["skip_metadata"]);
1306
1355
  const resummarize = Boolean(opts["resummarize"]);
1307
1356
  const summaryThresholdChars = parsePositiveIntOption(opts["summary_threshold_chars"], "--summary-threshold-chars", "USAGE ERROR: Invalid summary threshold", DEFAULT_SUMMARY_REDUCE_THRESHOLD_CHARS);
1308
- 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);
1309
1358
  const batchMode = strOf(opts["batch_mode"] || DEFAULT_BATCH_MODE).trim();
1310
1359
  if (batchMode !== "episode") {
1311
1360
  throw new CliError("USAGE ERROR: Invalid batch mode", "Invalid batch mode.", {
@@ -1326,6 +1375,66 @@ async function commandInitImpl(opts) {
1326
1375
  const batchTargetLines = batchValues["batch_target_lines"];
1327
1376
  const batchMaxChars = batchValues["batch_max_chars"];
1328
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) {
1329
1438
  if (!exists(source) || !fs.statSync(source).isFile()) {
1330
1439
  throw new CliError("INIT BLOCKED: Source file not found", "Source file not found.", {
1331
1440
  exitCode: EXIT_INPUT,
@@ -1344,26 +1453,350 @@ async function commandInitImpl(opts) {
1344
1453
  nextSteps: ["Use a supported source file and rerun init."],
1345
1454
  });
1346
1455
  }
1347
- const dd = directDir(workspace);
1348
- fs.mkdirSync(dd, { recursive: true });
1349
- 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, {
1350
1555
  status: "init_running",
1351
1556
  command: "direct init",
1352
1557
  init_stage: "source_prepare",
1353
- provider: providerName,
1354
- model,
1355
- concurrency,
1356
- source_path: path.resolve(source),
1357
- 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,
1358
1563
  });
1359
- 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");
1360
1793
  let info;
1361
1794
  try {
1362
- info = await prepareSource(source, workspace);
1795
+ info = await prepareSource(source, options.workspace);
1363
1796
  }
1364
1797
  catch (exc) {
1365
1798
  if (exc instanceof CliError) {
1366
- updateRunState(workspace, {
1799
+ ctx.updateRunState({
1367
1800
  status: "init_failed",
1368
1801
  init_stage: "source_prepare",
1369
1802
  last_error: { title: exc.title, received: exc.received, failed_at: checkpointTimestamp() },
@@ -1372,7 +1805,7 @@ async function commandInitImpl(opts) {
1372
1805
  }
1373
1806
  const e = exc;
1374
1807
  const receivedError = `${source}: ${e?.name ?? "Error"}${e?.message ? `: ${e.message}` : ""}`;
1375
- updateRunState(workspace, {
1808
+ ctx.updateRunState({
1376
1809
  status: "init_failed",
1377
1810
  init_stage: "source_prepare",
1378
1811
  last_error: { title: "INIT BLOCKED: Source preparation failed", received: [receivedError], failed_at: checkpointTimestamp() },
@@ -1385,108 +1818,102 @@ async function commandInitImpl(opts) {
1385
1818
  });
1386
1819
  }
1387
1820
  const sourceTextPath = strOf(info["sourceTextPath"]);
1388
- const sourceText = readText(sourceTextPath);
1389
- const manifest = makeSourceManifest(source, sourceTextPath, info);
1390
- updateRunState(workspace, { status: "init_running", init_stage: "episode_plan" });
1391
- 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");
1392
1832
  let plan;
1393
1833
  try {
1394
1834
  plan = buildEpisodePlan(sourceText);
1395
1835
  }
1396
1836
  catch (exc) {
1397
- const e = exc;
1398
- throw initFailedReport(workspace, {
1837
+ stageFailure(options.workspace, exc, {
1399
1838
  title: "INIT FAILED: Episode planning failed",
1400
1839
  stage: "episode_plan",
1401
1840
  required: ["source.txt that can be split into episodes"],
1402
- received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1403
1841
  nextSteps: ["Inspect workspace/source.txt, fix the source file, and rerun init."],
1404
1842
  });
1405
1843
  }
1406
- updateRunState(workspace, { status: "init_running", init_stage: "episode_titles" });
1407
- 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);
1408
1863
  if (capabilities && !capabilities.extractBatch && !capabilities.extractEpisode) {
1409
- throw initFailedReport(workspace, {
1410
- title: "INIT FAILED: provider lacks batch extraction",
1411
- stage: "episode_titles",
1412
- required: ["provider.extractBatch or provider.extractEpisode"],
1413
- received: [`--provider ${providerName}`],
1414
- nextSteps: ["Use --provider anthropic or --provider mock for direct init."],
1415
- });
1416
- }
1417
- let provider;
1418
- try {
1419
- provider = makeProvider(providerName, model);
1420
- }
1421
- catch (exc) {
1422
- if (exc instanceof CliError) {
1423
- updateRunState(workspace, {
1424
- status: "init_failed",
1425
- init_stage: "episode_titles",
1426
- last_error: { title: exc.title, received: exc.received, failed_at: checkpointTimestamp() },
1427
- });
1428
- }
1429
- throw exc;
1864
+ throw lacksExtraction();
1430
1865
  }
1866
+ const provider = ctx.provider();
1431
1867
  if (!provider.extractBatch && !provider.extractEpisode) {
1432
- throw initFailedReport(workspace, {
1433
- title: "INIT FAILED: provider lacks batch extraction",
1434
- stage: "episode_titles",
1435
- required: ["provider.extractBatch or provider.extractEpisode"],
1436
- received: [`--provider ${providerName}`],
1437
- nextSteps: ["Use --provider anthropic or --provider mock for direct init."],
1438
- });
1439
- }
1440
- updateRunState(workspace, { status: "init_running", init_stage: "episode_titles" });
1441
- emitInitProgress(workspace, "episode_titles");
1442
- 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);
1443
1881
  let batchPlan;
1444
1882
  try {
1445
1883
  batchPlan = buildBatchPlan(sourceText, plan, {
1446
- targetLines: batchTargetLines,
1447
- maxChars: batchMaxChars,
1448
- minLines: batchMinLines,
1449
- mode: batchMode,
1884
+ targetLines: options.batchTargetLines,
1885
+ maxChars: options.batchMaxChars,
1886
+ minLines: options.batchMinLines,
1887
+ mode: options.batchMode,
1450
1888
  });
1451
1889
  }
1452
1890
  catch (exc) {
1453
- const e = exc;
1454
- throw initFailedReport(workspace, {
1891
+ stageFailure(options.workspace, exc, {
1455
1892
  title: "INIT FAILED: Batch planning failed",
1456
1893
  stage: "batch_plan",
1457
1894
  required: ["episode_plan.json that can be split into natural paragraph batches"],
1458
- received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1459
1895
  nextSteps: ["Inspect workspace/source.txt and episode_plan.json, then rerun init."],
1460
1896
  });
1461
1897
  }
1462
- writeJson(path.join(dd, "source_manifest.json"), manifest);
1463
- writeJson(path.join(dd, "episode_plan.json"), plan);
1464
- writeJson(path.join(dd, "batch_plan.json"), batchPlan);
1465
- emitInitProgress(workspace, "batch_plan", "success");
1466
- const episodeResultsDir = path.join(dd, "episode_results");
1467
- const batchResultsDir = path.join(dd, "batch_results");
1468
- fs.mkdirSync(episodeResultsDir, { recursive: true });
1469
- fs.mkdirSync(batchResultsDir, { recursive: true });
1470
- // Non-destructive GC: drop result/meta/error/md files for units the current
1471
- // plan no longer references (e.g. the source shed an episode). Pure function
1472
- // of the plan — it never touches a unit the plan still references, and retires
1473
- // 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.
1474
1912
  gcOrphanUnits(episodeResultsDir, new Set(asList(plan["episodes"]).map((ep) => episodeResultKey(ep))));
1475
1913
  gcOrphanUnits(batchResultsDir, new Set(asList(batchPlan["batches"]).map((b) => batchResultKey(b))));
1476
- const batchesByEpisode = new Map();
1477
- for (const batch of asList(batchPlan["batches"])) {
1478
- const epNum = Number(batch["episode"]);
1479
- if (!batchesByEpisode.has(epNum))
1480
- batchesByEpisode.set(epNum, []);
1481
- batchesByEpisode.get(epNum).push(batch);
1482
- }
1483
- const totalEpisodes = asList(plan["episodes"]).length;
1484
- const totalBatches = asList(batchPlan["batches"]).length;
1485
- // Decide which batches to run. State (classifyBatch) is shared with `direct
1486
- // status` so the two never disagree: a "done" batch is reused, an "error"
1487
- // batch is sticky (skipped on a plain rerun so a content filter can't loop),
1488
- // a "pending" (never-run or corrupt) batch runs. The agent overrides with
1489
- // 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).
1490
1917
  const pending = [];
1491
1918
  let reusedBatchCount = 0;
1492
1919
  let stickyErrorCount = 0;
@@ -1494,7 +1921,7 @@ async function commandInitImpl(opts) {
1494
1921
  const epNum = Number(batch["episode"]);
1495
1922
  const key = batchResultKey(batch);
1496
1923
  const errorPath = batchErrorPath(batchResultsDir, batch);
1497
- 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));
1498
1925
  if (forced) {
1499
1926
  const resultPath = batchResultPath(batchResultsDir, batch);
1500
1927
  if (exists(resultPath))
@@ -1510,74 +1937,98 @@ async function commandInitImpl(opts) {
1510
1937
  break;
1511
1938
  case "error":
1512
1939
  stickyErrorCount++;
1513
- break; // tried and failed; left until explicitly re-run
1514
- default: pending.push(batch); // never attempted (or corrupt → self-heal)
1940
+ break;
1941
+ default: pending.push(batch);
1515
1942
  }
1516
1943
  }
1517
- // Episodes whose batches were (re)run this round — their episode synopsis is
1518
- // recomputed; untouched episodes reuse their persisted `syn`. Summary failures
1519
- // accumulate here and are reported without ever blocking init.
1520
- const changedEpisodes = new Set(pending.map((b) => Number(b["episode"])));
1521
- const summaryDegradedEpisodes = [];
1522
- 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({
1523
1949
  status: "init_running",
1524
1950
  init_stage: "batch_extract",
1525
- batch_mode: batchMode,
1526
- batch_target_lines: batchTargetLines,
1527
- batch_max_chars: batchMaxChars,
1528
- batch_min_lines: batchMinLines,
1529
- episode_total: totalEpisodes,
1530
- 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,
1531
1957
  });
1532
- emitInitProgress(workspace, "batch_extract");
1533
- // Persist each batch the moment it finishes (inside the worker), NOT after the
1534
- // whole pool drains. Per-unit files have no shared index, so concurrent writes
1535
- // are safe — and batch_results/ fills up live, so a crash/kill mid-run keeps
1536
- // every batch already extracted (the resume model depends on this).
1537
- const outcomes = await pMapWithConcurrency(pending, concurrency, async (batch) => {
1958
+ ctx.emitProgress("batch-extract");
1959
+ const outcomes = await pMapWithConcurrency(pending, options.concurrency, async (batch) => {
1538
1960
  try {
1539
1961
  const result = await extractBatchWithRecovery(provider, sourceText, batch);
1540
1962
  persistBatchResult(batchResultsDir, batch, result);
1541
1963
  const errorPath = batchErrorPath(batchResultsDir, batch);
1542
1964
  if (exists(errorPath))
1543
1965
  deletePath(errorPath);
1544
- emitInitProgress(workspace, "batch_extract");
1966
+ ctx.emitProgress("batch-extract");
1545
1967
  return true;
1546
1968
  }
1547
1969
  catch (error) {
1548
1970
  writeBatchFailure(batchResultsDir, batch, error);
1549
- emitInitProgress(workspace, "batch_extract");
1971
+ ctx.emitProgress("batch-extract");
1550
1972
  return false;
1551
1973
  }
1552
1974
  });
1553
1975
  const freshOk = outcomes.filter((o) => o.ok && o.value === true).length;
1554
1976
  const freshFail = outcomes.filter((o) => o.ok && o.value === false).length;
1555
- const doneBatches = reusedBatchCount + freshOk;
1556
- const erroredBatches = stickyErrorCount + freshFail;
1557
- // Assemble every episode whose batches are all present on disk (reused +
1558
- // freshly extracted, read uniformly from disk). Incomplete episodes are
1559
- // 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
+ }
1560
2014
  const results = [];
1561
2015
  const incompleteEpisodes = [];
1562
- updateRunState(workspace, {
2016
+ ctx.updateRunState({
1563
2017
  status: "init_running",
1564
2018
  init_stage: "episode_merge",
1565
- episode_total: totalEpisodes,
1566
- batch_total: totalBatches,
1567
- batch_completed: doneBatches,
1568
- batch_failed: erroredBatches,
2019
+ episode_total: state.totalEpisodes,
2020
+ batch_total: state.totalBatches,
2021
+ batch_completed: state.doneBatches,
2022
+ batch_failed: state.erroredBatches,
1569
2023
  });
1570
- emitInitProgress(workspace, "episode_merge");
2024
+ ctx.emitProgress("episode-merge");
1571
2025
  try {
1572
- // Assemble every complete episode (incomplete ones are reported and skipped).
1573
2026
  for (const episode of asList(plan["episodes"])) {
1574
2027
  const episodeNum = Number(episode["episode"]);
1575
2028
  const epBatches = batchesByEpisode.get(episodeNum) ?? [];
1576
2029
  const batchResults = epBatches.map((b) => readBatchResult(batchResultsDir, b)).filter((r) => r !== null);
1577
2030
  if (epBatches.length === 0 || batchResults.length !== epBatches.length) {
1578
2031
  incompleteEpisodes.push(episodeNum);
1579
- // Drop any stale episode_results/ep_NNN.json from a prior complete run so
1580
- // the derived artifact never claims an episode is complete when it isn't.
1581
2032
  const stale = episodeResultPath(episodeResultsDir, episode);
1582
2033
  if (exists(stale))
1583
2034
  deletePath(stale);
@@ -1585,194 +2036,180 @@ async function commandInitImpl(opts) {
1585
2036
  }
1586
2037
  const merged = mergeBatchResultsForEpisode(episode, batchResults);
1587
2038
  validateEpisodeExtractionQuality(sourceText, episode, merged);
1588
- results.push(merged); // complete episodes only; carries `episode` for the write below
2039
+ results.push(merged);
1589
2040
  }
1590
- emitInitProgress(workspace, "episode_merge", incompleteEpisodes.length > 0 ? "warning" : "success");
1591
- // Episode-level synopsis (reduce-1): resolve each result's synopsis in place
1592
- // (passthrough / reuse / skip / concurrent LLM stitch). Reads prior `syn`
1593
- // BEFORE the persist loop below overwrites ep_NNN.json.
1594
- updateRunState(workspace, { status: "init_running", init_stage: "episode_synopsis" });
1595
- emitInitProgress(workspace, "episode_synopsis");
1596
- summaryDegradedEpisodes.push(...await resolveEpisodeSynopses(provider, results, {
1597
- resultsDir: episodeResultsDir,
1598
- resummarize,
1599
- skipSummary,
1600
- changedEpisodes,
1601
- concurrency,
1602
- }));
1603
- // Persist every assembled episode (synopsis now resolved for all of them).
1604
- // merged carries `episode`, so it doubles as the path key.
1605
- for (const merged of results) {
1606
- writeJson(episodeResultPath(episodeResultsDir, merged), compactEpisodeResult(merged));
1607
- const errPath = episodeErrorPath(episodeResultsDir, merged);
1608
- if (exists(errPath))
1609
- deletePath(errPath);
1610
- }
1611
- emitInitProgress(workspace, "episode_synopsis", summaryDegradedEpisodes.length > 0 ? "warning" : "success");
1612
2041
  }
1613
2042
  catch (exc) {
1614
- const e = exc;
1615
- throw initFailedReport(workspace, {
2043
+ stageFailure(ctx.options.workspace, exc, {
1616
2044
  title: "INIT FAILED: Episode merge failed",
1617
2045
  stage: "episode_merge",
1618
2046
  required: ["batch_results/*.json that can merge into episode_results/*.json"],
1619
- received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1620
2047
  nextSteps: ["Rerun init; completed batches are reused and episode merge will retry."],
1621
2048
  });
1622
2049
  }
1623
- results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
1624
- incompleteEpisodes.sort((a, b) => a - b);
1625
- if (incompleteEpisodes.length > 0) {
1626
- 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({
1627
2082
  status: "init_incomplete",
1628
2083
  init_stage: "batch_extract",
1629
- episode_total: totalEpisodes,
1630
- episode_completed: results.length,
1631
- incomplete_episodes: incompleteEpisodes,
1632
- batch_total: totalBatches,
1633
- batch_completed: doneBatches,
1634
- 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,
1635
2090
  last_error: { title: "INIT INCOMPLETE: Some episodes have unextracted batches", failed_at: checkpointTimestamp() },
1636
2091
  });
1637
- emitInitProgress(workspace, "batch_extract", "error");
1638
- const report = {
1639
- title: "INIT INCOMPLETE: Some episodes have unextracted batches",
1640
- result: [
1641
- `episodes: ${results.length}/${totalEpisodes} complete`,
1642
- `incomplete episodes: ${incompleteEpisodes.join(", ")}`,
1643
- `content: ${doneBatches}/${totalBatches} done, ${erroredBatches} error`,
1644
- ...(titleDegraded.length > 0 ? [`titles degraded (deterministic): ${titleDegraded.join(", ")}`] : []),
1645
- `provider: ${providerName}`,
1646
- ],
1647
- artifacts: [
1648
- path.join(workspace, "source.txt"),
1649
- path.join(dd, "episode_plan.json"),
1650
- path.join(dd, "batch_plan.json"),
1651
- batchResultsDir,
1652
- episodeResultsDir,
1653
- path.join(dd, "run_state.json"),
1654
- ],
1655
- next: [
1656
- "Run `direct status` to see episode progress.",
1657
- "Re-run one episode with `direct init --episodes <n>`, or all errors with `--retry-errors`.",
1658
- "Errored units are left untouched on a plain rerun; select them to retry.",
1659
- ],
1660
- };
1661
- return [report, EXIT_RUNTIME];
2092
+ ctx.emitProgress("batch-extract", "error");
2093
+ throw new StageIncomplete();
1662
2094
  }
1663
- 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({
1664
2102
  status: "init_running",
1665
2103
  init_stage: "script_merge",
1666
- episode_total: totalEpisodes,
2104
+ episode_total: state.totalEpisodes || asList((ensureEpisodePlan(ctx))["episodes"]).length,
1667
2105
  episode_completed: results.length,
1668
2106
  incomplete_episodes: [],
1669
- batch_total: totalBatches,
1670
- batch_completed: doneBatches,
1671
- batch_failed: erroredBatches,
2107
+ batch_total: state.totalBatches,
2108
+ batch_completed: state.doneBatches,
2109
+ batch_failed: state.erroredBatches,
1672
2110
  last_error: null,
1673
2111
  });
1674
- let script;
1675
- const assetNormalizationPath = path.join(dd, "asset_normalization.json");
1676
- const assetMetadataPath = path.join(dd, "asset_metadata.json");
1677
- let assetNormalization = null;
2112
+ ctx.emitProgress("script-merge");
1678
2113
  try {
1679
- emitInitProgress(workspace, "script_merge");
1680
- const merged = mergeEpisodeResultsWithReport(results, strOf(info["projectName"]) || path.basename(source, path.extname(source)));
1681
- script = merged.script;
1682
- assetNormalization = merged.assetNormalization;
1683
- 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);
1684
2118
  }
1685
2119
  catch (exc) {
1686
- const e = exc;
1687
- throw initFailedReport(workspace, {
2120
+ stageFailure(options.workspace, exc, {
1688
2121
  title: "INIT FAILED: Merge failed",
1689
2122
  stage: "script_merge",
1690
2123
  required: ["complete episode_results/*.json"],
1691
- received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1692
2124
  nextSteps: ["Rerun init; completed episode extraction checkpoints will be reused and merge will retry."],
1693
2125
  updates: { episode_completed: results.length },
1694
2126
  });
1695
2127
  }
2128
+ }
2129
+ // --- asset-curation -------------------------------------------------------
2130
+ export async function stageAssetCuration(ctx) {
2131
+ const { ws, state, options } = ctx;
2132
+ const script = ensureScript(ctx);
1696
2133
  try {
1697
- updateRunState(workspace, { status: "init_running", init_stage: "asset_curation" });
1698
- emitInitProgress(workspace, "asset_curation");
1699
- // Location merge is deterministic-only: actor/prop pruning runs from scene
1700
- // counts and every location is kept. The previous provider location-merge
1701
- // call was removed (single large streaming request kept dropping the gateway
1702
- // connection); duplicate locations can be merged later if needed.
2134
+ ctx.updateRunState({ status: "init_running", init_stage: "asset_curation" });
2135
+ ctx.emitProgress("asset-curation");
1703
2136
  const curation = curateScriptAssets(script);
1704
- writeJson(path.join(dd, "asset_curation.json"), curation);
2137
+ ws.writeJson("asset_curation.json", curation);
1705
2138
  }
1706
2139
  catch (exc) {
1707
- // curateScriptAssets is deterministic (no provider call) — a failure here is
1708
- // a malformed assembled script, not a gateway/provider problem.
1709
- const e = exc;
1710
- throw initFailedReport(workspace, {
2140
+ stageFailure(options.workspace, exc, {
1711
2141
  title: "INIT FAILED: Asset curation failed",
1712
2142
  stage: "asset_curation",
1713
2143
  required: ["a well-formed assembled script for deterministic asset curation"],
1714
- received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1715
2144
  nextSteps: ["Rerun init; completed extraction checkpoints will be reused."],
1716
- updates: { episode_completed: results.length },
2145
+ updates: { episode_completed: state.mergedResults.length },
1717
2146
  });
1718
2147
  }
1719
- emitInitProgress(workspace, "asset_curation", "success");
1720
- if (skipMetadata) {
1721
- 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 });
1722
2158
  if (exists(assetMetadataPath))
1723
2159
  deletePath(assetMetadataPath);
1724
- emitInitProgress(workspace, "metadata_extract", "skipped");
2160
+ ctx.emitProgress("metadata", "skipped");
2161
+ return;
1725
2162
  }
1726
- else {
1727
- try {
1728
- updateRunState(workspace, { status: "init_running", init_stage: "metadata_extract", metadata_skipped: false });
1729
- emitInitProgress(workspace, "metadata_extract");
1730
- let metadata = provider.extractMetadata ? await provider.extractMetadata(sourceText, script) : {};
1731
- if (!isDict(metadata))
1732
- metadata = {};
1733
- writeJson(assetMetadataPath, metadata);
1734
- applyMetadataToScript(script, metadata);
1735
- }
1736
- catch (exc) {
1737
- if (exc instanceof CliError) {
1738
- throw initFailedReport(workspace, {
1739
- title: exc.title,
1740
- stage: "metadata_extract",
1741
- exitCode: exc.exitCode,
1742
- errorCode: exc.errorCode,
1743
- required: exc.required.length > 0 ? exc.required : ["metadata JSON matching final script contract"],
1744
- received: exc.received.length > 0 ? exc.received : [String(exc.message).slice(0, 160)],
1745
- nextSteps: exc.nextSteps.length > 0 ? exc.nextSteps : ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
1746
- updates: { episode_completed: results.length },
1747
- });
1748
- }
1749
- const e = exc;
1750
- throw initFailedReport(workspace, {
1751
- 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,
1752
2177
  stage: "metadata_extract",
1753
- required: ["provider metadata for worldview, role_type, and asset descriptions"],
1754
- received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1755
- nextSteps: ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
1756
- 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 },
1757
2184
  });
1758
2185
  }
1759
- 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
+ });
1760
2193
  }
1761
- // Whole-script overview (reduce-2). Soft: a non-essential concept enhancement,
1762
- // the opposite of batch extraction's hard-fail policy — failures degrade the
1763
- // 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();
1764
2201
  let summaryFullDegraded = false;
1765
2202
  let summaryDegradeReason = "";
1766
2203
  try {
1767
- updateRunState(workspace, { status: "init_running", init_stage: "script_synopsis" });
1768
- emitInitProgress(workspace, "script_synopsis");
2204
+ ctx.updateRunState({ status: "init_running", init_stage: "script_synopsis" });
2205
+ ctx.emitProgress("script-synopsis");
1769
2206
  const summary = await buildScriptSynopsis(provider, script, {
1770
- dd,
1771
- thresholdChars: summaryThresholdChars,
2207
+ dd: ws.dir,
2208
+ thresholdChars: options.summaryThresholdChars,
1772
2209
  groupSize: DEFAULT_SUMMARY_GROUP_SIZE,
1773
- resummarize,
1774
- skipSummary,
1775
- concurrency,
2210
+ resummarize: options.resummarize,
2211
+ skipSummary: options.skipSummary,
2212
+ concurrency: options.concurrency,
1776
2213
  });
1777
2214
  summaryFullDegraded = summary.degraded;
1778
2215
  summaryDegradeReason = summary.reason ?? "";
@@ -1781,116 +2218,44 @@ async function commandInitImpl(opts) {
1781
2218
  summaryFullDegraded = true;
1782
2219
  summaryDegradeReason = providerErrorSummary(exc);
1783
2220
  }
1784
- summaryDegradedEpisodes.sort((a, b) => a - b); // concurrent stitch pushes out of order
1785
- const summaryDegraded = summaryFullDegraded || summaryDegradedEpisodes.length > 0;
1786
- const scriptPath = path.join(dd, "script.initial.json");
1787
- writeJson(scriptPath, script);
1788
- updateRunState(workspace, {
1789
- 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,
1790
2230
  summary_degraded: summaryDegraded,
1791
- summary_degraded_episodes: summaryDegradedEpisodes,
2231
+ summary_degraded_episodes: state.summaryDegradedEpisodes,
1792
2232
  summary_degraded_reason: summaryDegradeReason || null,
1793
2233
  });
1794
- emitInitProgress(workspace, "script_synopsis", summaryDegraded ? "warning" : skipSummary ? "skipped" : "success");
1795
- updateRunState(workspace, { status: "init_running", init_stage: "validate" });
1796
- 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");
1797
2243
  let validation;
1798
2244
  try {
1799
- validation = validateScript(workspace, scriptPath);
2245
+ validation = validateScript(options.workspace, scriptPath);
1800
2246
  }
1801
2247
  catch (exc) {
1802
- const e = exc;
1803
- throw initFailedReport(workspace, {
2248
+ stageFailure(options.workspace, exc, {
1804
2249
  title: "INIT FAILED: Validation failed",
1805
2250
  stage: "validate",
1806
2251
  required: ["script.initial.json that can be validated"],
1807
- received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1808
2252
  nextSteps: ["Rerun init to retry validation, or inspect script.initial.json if the failure persists."],
1809
2253
  updates: { script_path: scriptPath },
1810
2254
  });
1811
2255
  }
2256
+ state.validation = validation;
1812
2257
  const passed = Boolean(validation["passed"]);
1813
- const status = passed ? "ready_for_agent" : "needs_agent_repair";
1814
- updateRunState(workspace, {
1815
- status,
1816
- command: "direct init",
1817
- init_stage: "complete",
1818
- provider: providerName,
1819
- model,
1820
- concurrency,
1821
- batch_mode: batchMode,
1822
- batch_target_lines: batchTargetLines,
1823
- batch_max_chars: batchMaxChars,
1824
- batch_min_lines: batchMinLines,
1825
- source_path: path.resolve(source),
1826
- script_path: scriptPath,
1827
- validation_path: path.join(dd, "validation.json"),
1828
- episode_total: totalEpisodes,
1829
- episode_completed: results.length,
1830
- incomplete_episodes: [],
1831
- batch_total: totalBatches,
1832
- batch_completed: doneBatches,
1833
- batch_reused: reusedBatchCount,
1834
- batch_failed: erroredBatches,
1835
- summary_threshold_chars: summaryThresholdChars,
1836
- summary_skipped: skipSummary,
1837
- summary_degraded: summaryDegraded,
1838
- summary_degraded_episodes: summaryDegradedEpisodes,
1839
- summary_degraded_reason: summaryDegradeReason || null,
1840
- metadata_skipped: skipMetadata,
1841
- last_error: null,
1842
- });
1843
- emitInitProgress(workspace, "validate", passed ? "success" : "warning");
1844
- const title = passed
1845
- ? "INIT COMPLETE: Initial script ready"
1846
- : "INIT NEEDS AGENT: Initial script written with repair issues";
1847
- const stats = validation["stats"] ?? {};
1848
- const summaryLine = formatSummaryStatusLine(skipSummary, summaryDegraded, summaryDegradedEpisodes);
1849
- const normalizationSummary = assetNormalization?.summary ?? {
1850
- asset_merges: 0,
1851
- location_states_from_names: 0,
1852
- location_states_from_time: 0,
1853
- review_items: 0,
1854
- };
1855
- const report = {
1856
- title,
1857
- result: [
1858
- `episodes: ${stats["episodes"] ?? 0}`,
1859
- `scenes: ${stats["scenes"] ?? 0}`,
1860
- `actions: ${stats["actions"] ?? 0}`,
1861
- `validation: ${passed ? "passed" : "needs repair"}`,
1862
- `provider: ${providerName}`,
1863
- `episodes: ${results.length}/${totalEpisodes} complete`,
1864
- `content: ${doneBatches}/${totalBatches} done`,
1865
- `normalization: merges=${normalizationSummary.asset_merges}, states=${normalizationSummary.location_states_from_names + normalizationSummary.location_states_from_time}, review=${normalizationSummary.review_items}`,
1866
- ...(skipMetadata ? ["metadata: skipped"] : []),
1867
- ...(titleDegraded.length > 0 ? [`titles degraded (deterministic): ${titleDegraded.join(", ")}`] : []),
1868
- ...(summaryLine ? [summaryLine] : []),
1869
- ],
1870
- artifacts: [
1871
- path.join(workspace, "source.txt"),
1872
- path.join(dd, "source_manifest.json"),
1873
- path.join(dd, "episode_plan.json"),
1874
- path.join(dd, "batch_plan.json"),
1875
- batchResultsDir,
1876
- episodeResultsDir,
1877
- assetNormalizationPath,
1878
- path.join(dd, "asset_curation.json"),
1879
- ...(skipMetadata ? [] : [assetMetadataPath]),
1880
- path.join(dd, "synopsis.json"),
1881
- scriptPath,
1882
- path.join(dd, "validation.json"),
1883
- path.join(dd, "run_state.json"),
1884
- ],
1885
- issues: summarizeIssues(asList(validation["issues"])),
1886
- next: providerName === "mock"
1887
- ? [
1888
- "Run `direct review` (add --episode <n> for source↔extract); edit with --draft if needed; then validate/export.",
1889
- "Do not export mock-provider results for delivery.",
1890
- ]
1891
- : ["Run `direct review` (add --episode <n> for source↔extract); edit with --draft if needed; then validate/export."],
1892
- };
1893
- return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
2258
+ ctx.emitProgress("validate", passed ? "success" : "warning");
1894
2259
  }
1895
2260
  // ---------------------------------------------------------------------------
1896
2261
  // command_status — the agent-facing state table, derived entirely from disk: a