@lingjingai/scriptctl 0.11.5 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_CONCURRENCY, DEFAULT_MODEL, DEFAULT_PROVIDER, DIRECT_CONTRACT_VERSION, EXIT_INPUT, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, REVIEW_TARGETS, SUPPORTED_EXTS, deletePath, directDir, exists, fmtId, readJson, readText, sha256Text, writeJson, } from "../common.js";
4
- import { compactBatchResult, compactEpisodeResult, buildBatchPlan, buildEpisodePlan, classifyProviderError, enrichEpisodePlanTitles, extractBatchWithRecovery, mergeEpisodeResults, normalizeEpisodeResult, normalizeInt, recoverBatchFromSource, uniqueAdd, validateBatchExtractionQuality, validateEpisodeExtractionQuality, _md_push_asset, curateScriptAssets, applyMetadataToScript, } from "../domain/direct-core.js";
3
+ import { CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_CONCURRENCY, DEFAULT_MODEL, DEFAULT_PROVIDER, EXIT_INPUT, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, REVIEW_TARGETS, SUPPORTED_EXTS, deletePath, directDir, exists, fmtId, readJson, readText, writeJson, } from "../common.js";
4
+ import { compactBatchResult, compactEpisodeResult, buildBatchPlan, buildEpisodePlan, enrichEpisodePlanTitles, extractBatchWithRecovery, mergeEpisodeResults, normalizeEpisodeResult, normalizeInt, uniqueAdd, validateEpisodeExtractionQuality, _md_push_asset, curateScriptAssets, applyMetadataToScript, } from "../domain/direct-core.js";
5
5
  import { validateScript } from "../domain/script-core.js";
6
6
  import { makeProvider } from "../infra/providers.js";
7
7
  import { makeSourceManifest, prepareSource, } from "../infra/converters.js";
@@ -23,6 +23,12 @@ function checkpointTimestamp() {
23
23
  const now = new Date();
24
24
  return now.toISOString().replace(/\.\d+Z$/, "Z");
25
25
  }
26
+ function parseIntList(v) {
27
+ return strOf(v).split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !Number.isNaN(n));
28
+ }
29
+ function parseStrList(v) {
30
+ return strOf(v).split(",").map((s) => s.trim()).filter((s) => s.length > 0);
31
+ }
26
32
  // ---------------------------------------------------------------------------
27
33
  // run_state.json
28
34
  // ---------------------------------------------------------------------------
@@ -163,54 +169,32 @@ function persistBatchResult(dir, batch, result) {
163
169
  deletePath(mdPath);
164
170
  }
165
171
  }
166
- function episodeMetaPath(dir, ep) {
167
- return path.join(dir, `${episodeResultKey(ep)}.meta.json`);
168
- }
169
- function batchMetaPath(dir, batch) {
170
- return path.join(dir, `${batchResultKey(batch)}.meta.json`);
171
- }
172
- function readUnitMeta(metaPath) {
173
- if (!exists(metaPath))
172
+ // ---------------------------------------------------------------------------
173
+ // Per-batch result files ARE the state. A batch with `<key>.json` is done; with
174
+ // `<key>.error.json` it failed; with neither it is pending. Reuse = file
175
+ // presence — the agent drives what to (re)run via `direct status` + --episodes/
176
+ // --batches selection. No content hashes, no global checkpoint, no destructive
177
+ // reset: re-running a unit only ever touches that unit's own files, so the other
178
+ // episodes can never be lost.
179
+ // ---------------------------------------------------------------------------
180
+ // Read a persisted batch result (reused or freshly written) for assembly.
181
+ // Returns null if absent or unreadable, in which case the batch counts as
182
+ // not-done and will be (re)run.
183
+ function readBatchResult(dir, batch) {
184
+ const p = batchResultPath(dir, batch);
185
+ if (!exists(p))
174
186
  return null;
175
187
  try {
176
- const data = readJson(metaPath);
177
- return isDict(data) ? data : null;
188
+ const result = normalizeEpisodeResult(readJson(p), batch);
189
+ result["_batch_id"] = batchResultKey(batch);
190
+ result["_batch_part"] = Number(batch["part"]);
191
+ result["_starts_inside_scene"] = Boolean(batch["starts_inside_scene"]);
192
+ return result;
178
193
  }
179
194
  catch {
180
195
  return null;
181
196
  }
182
197
  }
183
- function writeUnitMeta(metaPath, meta) {
184
- fs.mkdirSync(path.dirname(metaPath), { recursive: true });
185
- writeJson(metaPath, meta);
186
- }
187
- function removeUnitMeta(metaPath) {
188
- if (exists(metaPath))
189
- deletePath(metaPath);
190
- }
191
- function stampEpisodeMeta(dir, ep, inputHash, provenance, providerName, model) {
192
- writeUnitMeta(episodeMetaPath(dir, ep), {
193
- schema: 1, key: episodeResultKey(ep), episode: Number(ep["episode"]),
194
- input_hash: inputHash, provenance, status: "ok", provider: providerName, model, extracted_at: checkpointTimestamp(),
195
- });
196
- }
197
- function stampBatchMeta(dir, batch, inputHash, provenance, providerName, model) {
198
- writeUnitMeta(batchMetaPath(dir, batch), {
199
- schema: 1, key: batchResultKey(batch), episode: Number(batch["episode"]), part: Number(batch["part"]),
200
- input_hash: inputHash, provenance, status: "ok", provider: providerName, model, extracted_at: checkpointTimestamp(),
201
- });
202
- }
203
- // Content-address a single episode/batch plan unit: the contract version, the
204
- // exact source span text, the title-stable plan item, and provider/model. Any
205
- // change to what would alter extraction rotates the hash for THAT unit only.
206
- export function computeUnitHash(sourceText, unit, providerName, model) {
207
- const span = isDict(unit["source_span"]) ? unit["source_span"] : {};
208
- const start = Number(span["start"] ?? 0);
209
- const end = Number(span["end"] ?? 0);
210
- const spanText = sourceText.slice(start, end);
211
- const planText = JSON.stringify(unit, checkpointReplacer());
212
- return sha256Text([String(DIRECT_CONTRACT_VERSION), spanText, planText, providerName ?? "", model ?? ""].join("\u0000"));
213
- }
214
198
  // Delete result/meta/error/markdown files whose unit key is no longer in the
215
199
  // current plan (e.g. the source shed an episode). Pure function of the plan —
216
200
  // it never inspects hashes, content, or run_state, so it can only remove units
@@ -235,69 +219,6 @@ function gcOrphanUnits(dir, liveKeys) {
235
219
  }
236
220
  return removed;
237
221
  }
238
- // Title fields are LLM-mutated downstream by enrichEpisodePlanTitles, so they
239
- // must be excluded from unit hashes — otherwise every rerun gets a fresh SHA,
240
- // the cached unit never matches, and that unit re-extracts from scratch.
241
- const CHECKPOINT_UNSTABLE_KEYS = new Set(["title", "generated_title", "title_status", "title_source"]);
242
- function checkpointReplacer() {
243
- // Python's json.dumps(sort_keys=True) sorts keys recursively. Replicate by walking and sorting.
244
- return function (key, value) {
245
- if (CHECKPOINT_UNSTABLE_KEYS.has(key))
246
- return undefined;
247
- if (isDict(value)) {
248
- const sorted = {};
249
- for (const k of Object.keys(value).sort()) {
250
- if (CHECKPOINT_UNSTABLE_KEYS.has(k))
251
- continue;
252
- sorted[k] = value[k];
253
- }
254
- return sorted;
255
- }
256
- return value;
257
- };
258
- }
259
- // Non-destructive reuse: a cached episode result is reusable iff its sidecar
260
- // meta records the same input_hash we compute for the current plan unit. The
261
- // hash subsumes the old provider / source_span / episode-number / contract
262
- // checks — any of those changing rotates the hash. On any mismatch or read
263
- // failure we return null and let the caller re-extract and overwrite; we NEVER
264
- // delete the cached file pre-emptively (that was the data-loss root cause).
265
- export function loadCachedEpisode(sourceText, episodeResultsDir, ep, expectedHash) {
266
- const meta = readUnitMeta(episodeMetaPath(episodeResultsDir, ep));
267
- if (!meta || meta["input_hash"] !== expectedHash)
268
- return null;
269
- if (meta["status"] === "terminal")
270
- return null;
271
- const p = episodeResultPath(episodeResultsDir, ep);
272
- if (!exists(p))
273
- return null;
274
- try {
275
- const result = normalizeEpisodeResult(readJson(p), ep);
276
- validateEpisodeExtractionQuality(sourceText, ep, result);
277
- return result;
278
- }
279
- catch {
280
- return null;
281
- }
282
- }
283
- export function loadCachedBatch(sourceText, batchResultsDir, batch, expectedHash) {
284
- const meta = readUnitMeta(batchMetaPath(batchResultsDir, batch));
285
- if (!meta || meta["input_hash"] !== expectedHash)
286
- return null;
287
- if (meta["status"] === "terminal")
288
- return null;
289
- const p = batchResultPath(batchResultsDir, batch);
290
- if (!exists(p))
291
- return null;
292
- try {
293
- const result = normalizeEpisodeResult(readJson(p), batch);
294
- validateBatchExtractionQuality(sourceText, batch, result);
295
- return result;
296
- }
297
- catch {
298
- return null;
299
- }
300
- }
301
222
  function mergeScene(target, source) {
302
223
  if ((target["location_name"] === "" || target["location_name"] === "未知场景" || target["location_name"] === null || target["location_name"] === undefined) &&
303
224
  source["location_name"]) {
@@ -389,9 +310,12 @@ async function providerExtractAssetCurationLocal(provider, sourceText, script) {
389
310
  }
390
311
  return {};
391
312
  }
392
- function writeBatchFailure(dir, batch, exc, inputHash, providerName, model) {
313
+ // Record a batch failure: write `<key>.error.json` (which marks the batch as
314
+ // "tried and failed" → sticky on plain resume) and drop any stale result file.
315
+ // The error_code (e.g. PROVIDER_CONTENT_FILTERED) is surfaced so the agent can
316
+ // see WHY in `direct status` / `direct inspect` and decide whether to re-run.
317
+ function writeBatchFailure(dir, batch, exc) {
393
318
  const err = exc;
394
- const terminal = classifyProviderError(exc) === "terminal";
395
319
  const error = {
396
320
  batch_id: batchResultKey(batch),
397
321
  episode: Number(batch["episode"]),
@@ -400,11 +324,11 @@ function writeBatchFailure(dir, batch, exc, inputHash, providerName, model) {
400
324
  line_range: batch["line_range"],
401
325
  error_type: err?.name || "Error",
402
326
  message: (err?.message || err?.name || "Error").slice(0, 500),
403
- terminal,
404
- input_hash: inputHash,
405
327
  failed_at: checkpointTimestamp(),
406
328
  };
407
329
  if (exc instanceof CliError) {
330
+ if (exc.errorCode)
331
+ error["error_code"] = exc.errorCode;
408
332
  if (exc.required.length > 0)
409
333
  error["required"] = exc.required;
410
334
  if (exc.received.length > 0)
@@ -415,16 +339,6 @@ function writeBatchFailure(dir, batch, exc, inputHash, providerName, model) {
415
339
  const resultPath = batchResultPath(dir, batch);
416
340
  if (exists(resultPath))
417
341
  deletePath(resultPath);
418
- if (terminal) {
419
- writeUnitMeta(batchMetaPath(dir, batch), {
420
- schema: 1, key: batchResultKey(batch), episode: Number(batch["episode"]), part: Number(batch["part"]),
421
- input_hash: inputHash, provenance: "extracted", status: "terminal",
422
- provider: providerName, model, extracted_at: checkpointTimestamp(),
423
- });
424
- }
425
- else {
426
- removeUnitMeta(batchMetaPath(dir, batch));
427
- }
428
342
  writeJson(batchErrorPath(dir, batch), error);
429
343
  return error;
430
344
  }
@@ -479,9 +393,14 @@ export async function commandInit(opts) {
479
393
  const workspace = strOf(opts["workspace_path"] || "workspace");
480
394
  const providerName = strOf(opts["provider"] || DEFAULT_PROVIDER);
481
395
  const model = strOf(opts["model"] || process.env.SCRIPTCTL_ANTHROPIC_MODEL || DEFAULT_MODEL);
482
- // When set, retry batches a prior run marked terminal (content-filtered)
483
- // instead of skipping them e.g. after the provider's filter was adjusted.
484
- const retryTerminal = Boolean(opts["retry_terminal"]);
396
+ // Explicit selection: the agent decides what to (re)run. Default is "resume"
397
+ // only batches never attempted run. --episodes / --batches force-rerun the
398
+ // named units, --retry-errors force-reruns everything currently errored, --all
399
+ // reruns everything. Forced units have their stale result/error deleted first.
400
+ const selEpisodes = new Set(parseIntList(opts["episodes"]));
401
+ const selBatches = new Set(parseStrList(opts["batches"]));
402
+ const retryErrors = Boolean(opts["retry_errors"]);
403
+ const runAll = Boolean(opts["all"]);
485
404
  let concurrency;
486
405
  try {
487
406
  concurrency = parseInt(strOf(opts["concurrency"] || DEFAULT_CONCURRENCY), 10);
@@ -691,20 +610,6 @@ export async function commandInit(opts) {
691
610
  // the legacy v3 index.json. There is no whole-directory reset any more.
692
611
  gcOrphanUnits(episodeResultsDir, new Set(asList(plan["episodes"]).map((ep) => episodeResultKey(ep))));
693
612
  gcOrphanUnits(batchResultsDir, new Set(asList(batchPlan["batches"]).map((b) => batchResultKey(b))));
694
- updateRunState(workspace, {
695
- status: "init_running",
696
- init_stage: "batch_extract",
697
- batch_mode: batchMode,
698
- batch_target_lines: batchTargetLines,
699
- batch_max_chars: batchMaxChars,
700
- batch_min_lines: batchMinLines,
701
- episode_total: asList(plan["episodes"]).length,
702
- batch_total: asList(batchPlan["batches"]).length,
703
- });
704
- const results = [];
705
- const skipped = [];
706
- let skippedEpisodeBatchCount = 0;
707
- const pendingBatches = [];
708
613
  const batchesByEpisode = new Map();
709
614
  for (const batch of asList(batchPlan["batches"])) {
710
615
  const epNum = Number(batch["episode"]);
@@ -712,126 +617,85 @@ export async function commandInit(opts) {
712
617
  batchesByEpisode.set(epNum, []);
713
618
  batchesByEpisode.get(epNum).push(batch);
714
619
  }
715
- // Per-unit reuse: each episode is judged independently by its own input hash,
716
- // so a source edit to one episode invalidates only that episode — not all 33.
717
- for (const episode of asList(plan["episodes"])) {
718
- const epHash = computeUnitHash(sourceText, episode, providerName, model);
719
- const cached = loadCachedEpisode(sourceText, episodeResultsDir, episode, epHash);
720
- if (cached !== null) {
721
- results.push(cached);
722
- skipped.push(Number(episode["episode"]));
723
- const cachedBatches = batchesByEpisode.get(Number(episode["episode"])) ?? [];
724
- skippedEpisodeBatchCount += cachedBatches.length;
725
- for (const cachedBatch of cachedBatches) {
726
- if (!exists(batchResultPath(batchResultsDir, cachedBatch))) {
727
- const backfilled = recoverBatchFromSource(sourceText, cachedBatch);
728
- persistBatchResult(batchResultsDir, cachedBatch, backfilled);
729
- stampBatchMeta(batchResultsDir, cachedBatch, computeUnitHash(sourceText, cachedBatch, providerName, model), "recovered", providerName, model);
730
- }
731
- const errorPath = batchErrorPath(batchResultsDir, cachedBatch);
732
- if (exists(errorPath))
733
- deletePath(errorPath);
734
- }
735
- }
736
- else {
737
- pendingBatches.push(...(batchesByEpisode.get(Number(episode["episode"])) ?? []));
738
- }
739
- }
740
- const batchResults = [];
741
- const skippedBatches = [];
742
- const terminalSkipped = [];
620
+ const totalEpisodes = asList(plan["episodes"]).length;
621
+ const totalBatches = asList(batchPlan["batches"]).length;
622
+ // Decide which batches to run. Reuse = a result file is present; an error file
623
+ // makes a batch "sticky" (skipped on a plain rerun so a content filter can't
624
+ // loop). The agent overrides this with explicit selection.
743
625
  const pending = [];
744
- for (const batch of pendingBatches) {
745
- const bHash = computeUnitHash(sourceText, batch, providerName, model);
746
- // A terminal failure (content filter) with the same input hash will fail the
747
- // same way — skip it instead of re-calling the provider, unless --retry-terminal
748
- // or the source/provider changed (which rotates the hash).
749
- const meta = readUnitMeta(batchMetaPath(batchResultsDir, batch));
750
- if (!retryTerminal && meta && meta["status"] === "terminal" && meta["input_hash"] === bHash) {
751
- terminalSkipped.push(batchResultKey(batch));
752
- continue;
626
+ let reusedBatchCount = 0;
627
+ let stickyErrorCount = 0;
628
+ for (const batch of asList(batchPlan["batches"])) {
629
+ const epNum = Number(batch["episode"]);
630
+ const key = batchResultKey(batch);
631
+ const resultPath = batchResultPath(batchResultsDir, batch);
632
+ const errorPath = batchErrorPath(batchResultsDir, batch);
633
+ const forced = runAll || selEpisodes.has(epNum) || selBatches.has(key) || (retryErrors && exists(errorPath));
634
+ if (forced) {
635
+ if (exists(resultPath))
636
+ deletePath(resultPath);
637
+ if (exists(errorPath))
638
+ deletePath(errorPath);
639
+ pending.push(batch);
753
640
  }
754
- const cachedBatch = loadCachedBatch(sourceText, batchResultsDir, batch, bHash);
755
- if (cachedBatch !== null) {
756
- cachedBatch["_batch_id"] = batchResultKey(batch);
757
- cachedBatch["_batch_part"] = Number(batch["part"]);
758
- cachedBatch["_starts_inside_scene"] = Boolean(batch["starts_inside_scene"]);
759
- batchResults.push(cachedBatch);
760
- skippedBatches.push(batchResultKey(batch));
641
+ else if (exists(resultPath)) {
642
+ reusedBatchCount++;
643
+ }
644
+ else if (exists(errorPath)) {
645
+ stickyErrorCount++; // tried and failed; left alone until explicitly re-run
761
646
  }
762
647
  else {
763
- pending.push(batch);
648
+ pending.push(batch); // never attempted
764
649
  }
765
650
  }
766
- const failures = [];
767
- const outcomes = await pMapWithConcurrency(pending, concurrency, async (batch) => {
768
- return await extractBatchWithRecovery(provider, sourceText, batch);
651
+ updateRunState(workspace, {
652
+ status: "init_running",
653
+ init_stage: "batch_extract",
654
+ batch_mode: batchMode,
655
+ batch_target_lines: batchTargetLines,
656
+ batch_max_chars: batchMaxChars,
657
+ batch_min_lines: batchMinLines,
658
+ episode_total: totalEpisodes,
659
+ batch_total: totalBatches,
769
660
  });
661
+ const failures = [];
662
+ const outcomes = await pMapWithConcurrency(pending, concurrency, async (batch) => extractBatchWithRecovery(provider, sourceText, batch));
770
663
  for (let i = 0; i < outcomes.length; i++) {
771
664
  const outcome = outcomes[i];
772
665
  const batch = pending[i];
773
- const errorPath = batchErrorPath(batchResultsDir, batch);
774
666
  if (outcome.ok) {
775
- const result = outcome.value;
776
- result["_batch_id"] = batchResultKey(batch);
777
- result["_batch_part"] = Number(batch["part"]);
778
- result["_starts_inside_scene"] = Boolean(batch["starts_inside_scene"]);
779
- batchResults.push(result);
780
- persistBatchResult(batchResultsDir, batch, result);
781
- stampBatchMeta(batchResultsDir, batch, computeUnitHash(sourceText, batch, providerName, model), "extracted", providerName, model);
667
+ persistBatchResult(batchResultsDir, batch, outcome.value);
668
+ const errorPath = batchErrorPath(batchResultsDir, batch);
782
669
  if (exists(errorPath))
783
670
  deletePath(errorPath);
784
671
  }
785
672
  else {
786
- failures.push(writeBatchFailure(batchResultsDir, batch, outcome.error, computeUnitHash(sourceText, batch, providerName, model), providerName, model));
673
+ failures.push(writeBatchFailure(batchResultsDir, batch, outcome.error));
787
674
  }
788
675
  }
789
- results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
790
- batchResults.sort((a, b) => {
791
- const ea = Number(a["episode"] ?? 0);
792
- const eb = Number(b["episode"] ?? 0);
793
- if (ea !== eb)
794
- return ea - eb;
795
- return Number(a["_batch_part"] ?? 0) - Number(b["_batch_part"] ?? 0);
796
- });
797
- failures.sort((a, b) => {
798
- const ea = Number(a["episode"] ?? 0);
799
- const eb = Number(b["episode"] ?? 0);
800
- if (ea !== eb)
801
- return ea - eb;
802
- return Number(a["part"] ?? 0) - Number(b["part"] ?? 0);
803
- });
804
- const completedBatches = skippedEpisodeBatchCount + batchResults.length;
805
- const transientFailures = failures.filter((it) => !it["terminal"]);
806
- const terminalFailures = failures.filter((it) => Boolean(it["terminal"]));
807
- const skippedSet = new Set(skipped);
808
- // Merge every fully-completed, non-cached episode into an episode_results
809
- // checkpoint. Episodes still missing a batch (a failure this run, or a batch
810
- // a prior run marked terminal and we skipped) are left unmerged so a rerun or
811
- // an override can complete them.
812
- const batchResultsByEpisode = new Map();
813
- for (const result of batchResults) {
814
- const ep = Number(result["episode"] ?? 0);
815
- if (!batchResultsByEpisode.has(ep))
816
- batchResultsByEpisode.set(ep, []);
817
- batchResultsByEpisode.get(ep).push(result);
818
- }
676
+ const doneBatches = reusedBatchCount + outcomes.filter((o) => o.ok).length;
677
+ const erroredBatches = stickyErrorCount + failures.length;
678
+ // Assemble every episode whose batches are all present on disk (reused +
679
+ // freshly extracted, read uniformly from disk). Incomplete episodes are
680
+ // reported; assembling them is left to an explicit re-run.
681
+ const results = [];
682
+ const incompleteEpisodes = [];
819
683
  try {
820
684
  for (const episode of asList(plan["episodes"])) {
821
685
  const episodeNum = Number(episode["episode"]);
822
- if (skippedSet.has(episodeNum))
823
- continue;
824
- const expectedBatches = (batchesByEpisode.get(episodeNum) ?? []).length;
825
- if (!expectedBatches || (batchResultsByEpisode.get(episodeNum) ?? []).length !== expectedBatches)
686
+ const epBatches = batchesByEpisode.get(episodeNum) ?? [];
687
+ const batchResults = epBatches.map((b) => readBatchResult(batchResultsDir, b)).filter((r) => r !== null);
688
+ if (epBatches.length === 0 || batchResults.length !== epBatches.length) {
689
+ incompleteEpisodes.push(episodeNum);
826
690
  continue;
827
- const result = mergeBatchResultsForEpisode(episode, batchResultsByEpisode.get(episodeNum) ?? []);
828
- validateEpisodeExtractionQuality(sourceText, episode, result);
829
- results.push(result);
830
- writeJson(episodeResultPath(episodeResultsDir, episode), compactEpisodeResult(result));
831
- stampEpisodeMeta(episodeResultsDir, episode, computeUnitHash(sourceText, episode, providerName, model), "extracted", providerName, model);
832
- const errorPath = episodeErrorPath(episodeResultsDir, episode);
833
- if (exists(errorPath))
834
- deletePath(errorPath);
691
+ }
692
+ const merged = mergeBatchResultsForEpisode(episode, batchResults);
693
+ validateEpisodeExtractionQuality(sourceText, episode, merged);
694
+ results.push(merged);
695
+ writeJson(episodeResultPath(episodeResultsDir, episode), compactEpisodeResult(merged));
696
+ const errPath = episodeErrorPath(episodeResultsDir, episode);
697
+ if (exists(errPath))
698
+ deletePath(errPath);
835
699
  }
836
700
  }
837
701
  catch (exc) {
@@ -839,109 +703,60 @@ export async function commandInit(opts) {
839
703
  throw initFailedReport(workspace, {
840
704
  title: "INIT FAILED: Episode merge failed",
841
705
  stage: "episode_merge",
842
- required: ["complete batch_results/*.json that can merge into episode_results/*.json"],
706
+ required: ["batch_results/*.json that can merge into episode_results/*.json"],
843
707
  received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
844
- nextSteps: ["Rerun init; completed batch checkpoints will be reused and episode merge will retry."],
845
- updates: { batch_completed: completedBatches },
708
+ nextSteps: ["Rerun init; completed batches are reused and episode merge will retry."],
846
709
  });
847
710
  }
848
711
  results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
849
- // Classify episodes that could not be assembled. An episode blocked by ANY
850
- // transient batch (timeout/5xx) can still complete on rerun → it blocks init.
851
- // An episode blocked only by terminal (content-filtered) batches is held out:
852
- // the rest of the script ships, and the operator overrides the blocked unit.
853
- const completedEpisodeNums = new Set(results.map((r) => Number(r["episode"])));
854
- const transientEpisodeSet = new Set(transientFailures.map((it) => Number(it["episode"])));
855
- const incompleteEpisodes = asList(plan["episodes"]).map((ep) => Number(ep["episode"])).filter((n) => !completedEpisodeNums.has(n));
856
- const transientBlocked = incompleteEpisodes.filter((n) => transientEpisodeSet.has(n)).sort((a, b) => a - b);
857
- const heldOutEpisodes = incompleteEpisodes.filter((n) => !transientEpisodeSet.has(n)).sort((a, b) => a - b);
858
- if (transientBlocked.length > 0) {
712
+ incompleteEpisodes.sort((a, b) => a - b);
713
+ if (incompleteEpisodes.length > 0) {
859
714
  updateRunState(workspace, {
860
715
  status: "init_incomplete",
861
716
  init_stage: "batch_extract",
862
- episode_total: asList(plan["episodes"]).length,
717
+ episode_total: totalEpisodes,
863
718
  episode_completed: results.length,
864
- episode_reused: skipped.length,
865
- episode_failed: incompleteEpisodes.length,
866
- failed_episodes: transientBlocked,
867
- held_out_episodes: heldOutEpisodes,
868
- batch_total: asList(batchPlan["batches"]).length,
869
- batch_completed: completedBatches,
870
- batch_reused: skippedEpisodeBatchCount + skippedBatches.length,
871
- batch_failed: failures.length,
872
- batch_terminal: terminalFailures.length,
873
- transient_failed_batches: transientFailures.map((it) => strOf(it["batch_id"])),
874
- terminal_failed_batches: terminalFailures.map((it) => strOf(it["batch_id"])),
875
- last_error: { title: "INIT INCOMPLETE: Batch extraction failed", failed_at: checkpointTimestamp() },
719
+ incomplete_episodes: incompleteEpisodes,
720
+ batch_total: totalBatches,
721
+ batch_completed: doneBatches,
722
+ batch_failed: erroredBatches,
723
+ last_error: { title: "INIT INCOMPLETE: Some episodes have unextracted batches", failed_at: checkpointTimestamp() },
876
724
  });
877
- const issues = failures.slice(0, 5).map((it) => `${it["batch_id"]} episode ${it["episode"]} part ${it["part"]} [${it["terminal"] ? "terminal" : "transient"}]: ${it["error_type"]} - ${it["message"]}`);
878
- const next = [
879
- "Run direct inspect --target issue to review failed batches.",
880
- "Rerun the same init to retry transient failures; completed units are reused.",
881
- ];
882
- if (terminalFailures.length > 0) {
883
- next.push("Terminal (content-filtered) batches will not clear on retry — use `direct override <unit> --from <file>` or soften the source.");
884
- }
885
725
  const report = {
886
- title: "INIT INCOMPLETE: Batch extraction failed",
726
+ title: "INIT INCOMPLETE: Some episodes have unextracted batches",
887
727
  result: [
888
- `episodes total: ${asList(plan["episodes"]).length}`,
889
- `completed: ${results.length}`,
890
- `reused: ${skipped.length}`,
891
- `held out (terminal): ${heldOutEpisodes.length}`,
892
- `batches: ${completedBatches}/${asList(batchPlan["batches"]).length} completed, ${transientFailures.length} transient, ${terminalFailures.length} terminal`,
728
+ `episodes: ${results.length}/${totalEpisodes} complete`,
729
+ `incomplete episodes: ${incompleteEpisodes.join(", ")}`,
730
+ `batches: ${doneBatches}/${totalBatches} done, ${erroredBatches} error`,
893
731
  `provider: ${providerName}`,
894
732
  ],
895
733
  artifacts: [
896
734
  path.join(workspace, "source.txt"),
897
- path.join(dd, "source_manifest.json"),
898
735
  path.join(dd, "episode_plan.json"),
899
736
  path.join(dd, "batch_plan.json"),
900
737
  batchResultsDir,
901
738
  episodeResultsDir,
902
739
  path.join(dd, "run_state.json"),
903
740
  ],
904
- issues,
905
- next,
741
+ next: [
742
+ "Run `direct status` to see per-episode batch state.",
743
+ "Re-run one episode with `direct init --episodes <n>`, one batch with `--batches <key>`, or all errors with `--retry-errors`.",
744
+ "Errored batches are left untouched on a plain rerun; select them to retry.",
745
+ ],
906
746
  };
907
747
  return [report, EXIT_RUNTIME];
908
748
  }
909
749
  updateRunState(workspace, {
910
750
  status: "init_running",
911
751
  init_stage: "episode_merge",
912
- episode_total: asList(plan["episodes"]).length,
752
+ episode_total: totalEpisodes,
913
753
  episode_completed: results.length,
914
- episode_reused: skipped.length,
915
- episode_failed: 0,
916
- failed_episodes: [],
917
- held_out_episodes: heldOutEpisodes,
918
- batch_total: asList(batchPlan["batches"]).length,
919
- batch_completed: completedBatches,
920
- batch_reused: skippedEpisodeBatchCount + skippedBatches.length,
921
- batch_failed: terminalFailures.length,
922
- batch_terminal: terminalFailures.length,
754
+ incomplete_episodes: [],
755
+ batch_total: totalBatches,
756
+ batch_completed: doneBatches,
757
+ batch_failed: erroredBatches,
923
758
  last_error: null,
924
759
  });
925
- // Drop transient/cleared error markers, but KEEP terminal ones so `direct
926
- // status` and export gating can see which episodes are held out.
927
- for (const dir of [batchResultsDir, episodeResultsDir]) {
928
- if (!exists(dir))
929
- continue;
930
- for (const name of fs.readdirSync(dir)) {
931
- if (!name.endsWith(".error.json"))
932
- continue;
933
- const errPath = path.join(dir, name);
934
- try {
935
- const err = readJson(errPath);
936
- if (!isDict(err) || !err["terminal"])
937
- deletePath(errPath);
938
- }
939
- catch {
940
- deletePath(errPath);
941
- }
942
- }
943
- }
944
- results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
945
760
  let script;
946
761
  try {
947
762
  updateRunState(workspace, { status: "init_running", init_stage: "script_merge" });
@@ -1050,17 +865,13 @@ export async function commandInit(opts) {
1050
865
  source_path: path.resolve(source),
1051
866
  script_path: scriptPath,
1052
867
  validation_path: path.join(dd, "validation.json"),
1053
- episode_total: asList(plan["episodes"]).length,
868
+ episode_total: totalEpisodes,
1054
869
  episode_completed: results.length,
1055
- episode_reused: skipped.length,
1056
- episode_failed: 0,
1057
- failed_episodes: [],
1058
- held_out_episodes: heldOutEpisodes,
1059
- batch_total: asList(batchPlan["batches"]).length,
1060
- batch_completed: completedBatches,
1061
- batch_reused: skippedEpisodeBatchCount + skippedBatches.length,
1062
- batch_failed: terminalFailures.length,
1063
- batch_terminal: terminalFailures.length,
870
+ incomplete_episodes: [],
871
+ batch_total: totalBatches,
872
+ batch_completed: doneBatches,
873
+ batch_reused: reusedBatchCount,
874
+ batch_failed: erroredBatches,
1064
875
  last_error: null,
1065
876
  review_status: "pending",
1066
877
  review_missing: [...REVIEW_TARGETS],
@@ -1079,9 +890,8 @@ export async function commandInit(opts) {
1079
890
  `actions: ${stats["actions"] ?? 0}`,
1080
891
  `validation: ${passed ? "passed" : "needs repair"}`,
1081
892
  `provider: ${providerName}`,
1082
- `episodes reused: ${skipped.length}`,
1083
- `batches: ${completedBatches}/${asList(batchPlan["batches"]).length} completed`,
1084
- `batches reused: ${skippedEpisodeBatchCount + skippedBatches.length}`,
893
+ `episodes: ${results.length}/${totalEpisodes} complete`,
894
+ `batches: ${doneBatches}/${totalBatches} done (reused ${reusedBatchCount})`,
1085
895
  "agent_review: pending",
1086
896
  ],
1087
897
  artifacts: [
@@ -1108,138 +918,10 @@ export async function commandInit(opts) {
1108
918
  return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
1109
919
  }
1110
920
  // ---------------------------------------------------------------------------
1111
- // command_overrideinject a human extraction for a unit the provider can't
1112
- // produce (content-filtered). The override is content-addressed exactly like a
1113
- // provider result, so init reuses it and never re-calls the provider, and the
1114
- // non-destructive GC never deletes it. We compute the input_hash from the plan
1115
- // ourselves, so the operator never hand-edits source_span.
1116
- // ---------------------------------------------------------------------------
1117
- export function commandOverride(opts) {
1118
- const workspace = strOf(opts["workspace_path"] || "workspace");
1119
- const unit = strOf(asList(opts["_args"])[0]).trim();
1120
- const fromPath = strOf(opts["from"]).trim();
1121
- const dd = directDir(workspace);
1122
- const state = readRunState(workspace);
1123
- const providerName = strOf(opts["provider"] || state["provider"] || DEFAULT_PROVIDER);
1124
- const model = strOf(opts["model"] || state["model"] || DEFAULT_MODEL);
1125
- const isEpisode = /^ep_\d+$/.test(unit);
1126
- if (!isEpisode && !/^bat_\d+$/.test(unit)) {
1127
- throw new CliError("OVERRIDE BLOCKED: Invalid unit", "Invalid unit key.", {
1128
- exitCode: EXIT_USAGE,
1129
- required: ["<unit>: ep_NNN or bat_NNNN"],
1130
- received: [`<unit>: ${unit || "<empty>"}`],
1131
- nextSteps: ["Pass an episode (ep_007) or batch (bat_0012) key shown by direct status."],
1132
- });
1133
- }
1134
- if (!fromPath || !exists(fromPath)) {
1135
- throw new CliError("OVERRIDE BLOCKED: --from not found", "Override source file not found.", {
1136
- exitCode: EXIT_INPUT,
1137
- required: ["--from <path>: readable JSON extraction for the unit"],
1138
- received: [`--from: ${fromPath || "<missing>"}`],
1139
- nextSteps: ["Provide a JSON file with scenes/actions for the unit."],
1140
- });
1141
- }
1142
- const planPath = path.join(dd, isEpisode ? "episode_plan.json" : "batch_plan.json");
1143
- if (!exists(planPath)) {
1144
- throw new CliError("OVERRIDE BLOCKED: Plan not found", "Plan not found.", {
1145
- exitCode: EXIT_INPUT,
1146
- required: [isEpisode ? "episode_plan.json" : "batch_plan.json"],
1147
- received: [planPath],
1148
- nextSteps: ["Run scriptctl direct init first."],
1149
- });
1150
- }
1151
- const plan = readJson(planPath);
1152
- const planUnits = asList(plan[isEpisode ? "episodes" : "batches"]);
1153
- const planItem = planUnits.find((u) => (isEpisode ? episodeResultKey(u) : batchResultKey(u)) === unit) ?? null;
1154
- if (!planItem) {
1155
- throw new CliError("OVERRIDE BLOCKED: Unit not in current plan", "Unit not in current plan.", {
1156
- exitCode: EXIT_INPUT,
1157
- required: [`${unit} present in ${isEpisode ? "episode_plan.json" : "batch_plan.json"}`],
1158
- received: [`${unit}: not found among ${planUnits.length} units`],
1159
- nextSteps: ["Use a unit key from direct status; rerun init if the plan changed."],
1160
- });
1161
- }
1162
- const sourceTextPath = path.join(workspace, "source.txt");
1163
- if (!exists(sourceTextPath)) {
1164
- throw new CliError("OVERRIDE BLOCKED: source.txt missing", "source.txt missing.", {
1165
- exitCode: EXIT_INPUT,
1166
- required: [sourceTextPath],
1167
- received: ["<missing>"],
1168
- nextSteps: ["Run scriptctl direct init first."],
1169
- });
1170
- }
1171
- const sourceText = readText(sourceTextPath);
1172
- let data;
1173
- try {
1174
- data = readJson(fromPath);
1175
- }
1176
- catch (exc) {
1177
- throw new CliError("OVERRIDE BLOCKED: --from invalid JSON", "Override JSON invalid.", {
1178
- exitCode: EXIT_INPUT,
1179
- required: ["valid extraction JSON"],
1180
- received: [`${fromPath}: ${exc.message}`],
1181
- nextSteps: ["Fix the JSON and retry."],
1182
- });
1183
- }
1184
- const result = normalizeEpisodeResult(data, planItem);
1185
- if (Number(result["episode"]) !== Number(planItem["episode"])) {
1186
- throw new CliError("OVERRIDE BLOCKED: Episode mismatch", "Episode mismatch.", {
1187
- exitCode: EXIT_USAGE,
1188
- required: [`episode ${Number(planItem["episode"])}`],
1189
- received: [`episode ${Number(result["episode"])}`],
1190
- nextSteps: ["Provide an extraction for the correct episode."],
1191
- });
1192
- }
1193
- try {
1194
- if (isEpisode)
1195
- validateEpisodeExtractionQuality(sourceText, planItem, result);
1196
- else
1197
- validateBatchExtractionQuality(sourceText, planItem, result);
1198
- }
1199
- catch (exc) {
1200
- if (exc instanceof CliError)
1201
- throw exc;
1202
- throw new CliError("OVERRIDE BLOCKED: Extraction invalid", "Extraction invalid.", {
1203
- exitCode: EXIT_USAGE,
1204
- required: ["valid action types (dialogue/inner_thought/action)"],
1205
- received: [exc.message.slice(0, 160)],
1206
- nextSteps: ["Fix the override extraction and retry."],
1207
- });
1208
- }
1209
- const dir = path.join(dd, isEpisode ? "episode_results" : "batch_results");
1210
- fs.mkdirSync(dir, { recursive: true });
1211
- const hash = computeUnitHash(sourceText, planItem, providerName, model);
1212
- if (isEpisode) {
1213
- writeJson(episodeResultPath(dir, planItem), compactEpisodeResult(result));
1214
- stampEpisodeMeta(dir, planItem, hash, "override", providerName, model);
1215
- const errPath = episodeErrorPath(dir, planItem);
1216
- if (exists(errPath))
1217
- deletePath(errPath);
1218
- }
1219
- else {
1220
- persistBatchResult(dir, planItem, result);
1221
- stampBatchMeta(dir, planItem, hash, "override", providerName, model);
1222
- const errPath = batchErrorPath(dir, planItem);
1223
- if (exists(errPath))
1224
- deletePath(errPath);
1225
- }
1226
- const report = {
1227
- title: "OVERRIDE COMPLETE: Unit extraction injected",
1228
- result: [
1229
- `unit: ${unit}`,
1230
- `kind: ${isEpisode ? "episode" : "batch"}`,
1231
- `provenance: override`,
1232
- `provider/model: ${providerName} / ${model}`,
1233
- `scenes: ${asList(result["scenes"]).length}`,
1234
- ],
1235
- artifacts: [dir, path.join(dd, "run_state.json")],
1236
- next: ["Rerun scriptctl direct init — the override is reused without re-calling the provider."],
1237
- };
1238
- return [report, EXIT_OK];
1239
- }
1240
- // ---------------------------------------------------------------------------
1241
- // command_status — rebuild the progress view from on-disk meta/error sidecars.
1242
- // run_state is just a cache of this; deleting it loses nothing.
921
+ // command_statusthe agent-facing state table, derived entirely from disk: a
922
+ // batch with `<key>.json` is done, with `<key>.error.json` it failed, with
923
+ // neither it is pending. run_state is just a denormalized cache of this, so the
924
+ // agent can read state, then pick exactly which episodes/batches to (re)run.
1243
925
  // ---------------------------------------------------------------------------
1244
926
  export function commandStatus(opts) {
1245
927
  const workspace = strOf(opts["workspace_path"] || "workspace");
@@ -1256,54 +938,67 @@ export function commandStatus(opts) {
1256
938
  }
1257
939
  const episodes = asList(readJson(episodePlanPath)["episodes"]);
1258
940
  const batches = asList(readJson(batchPlanPath)["batches"]);
1259
- const episodeResultsDir = path.join(dd, "episode_results");
1260
941
  const batchResultsDir = path.join(dd, "batch_results");
1261
- const count = { ok: 0, override: 0, recovered: 0, terminal: 0, missing: 0 };
942
+ const byEp = new Map();
943
+ let doneTotal = 0;
944
+ let errorTotal = 0;
1262
945
  for (const batch of batches) {
1263
- const meta = readUnitMeta(batchMetaPath(batchResultsDir, batch));
1264
- if (!meta) {
1265
- count.missing++;
946
+ const epNum = Number(batch["episode"]);
947
+ if (!byEp.has(epNum))
948
+ byEp.set(epNum, { total: 0, done: 0, error: 0, errorKeys: [], reasons: new Set() });
949
+ const st = byEp.get(epNum);
950
+ st.total++;
951
+ if (exists(batchResultPath(batchResultsDir, batch))) {
952
+ st.done++;
953
+ doneTotal++;
1266
954
  continue;
1267
955
  }
1268
- if (meta["status"] === "terminal") {
1269
- count.terminal++;
1270
- continue;
956
+ const errPath = batchErrorPath(batchResultsDir, batch);
957
+ if (exists(errPath)) {
958
+ st.error++;
959
+ errorTotal++;
960
+ st.errorKeys.push(batchResultKey(batch));
961
+ try {
962
+ const err = readJson(errPath);
963
+ st.reasons.add(strOf(err["error_code"] || err["error_type"] || "Error"));
964
+ }
965
+ catch {
966
+ st.reasons.add("Error");
967
+ }
1271
968
  }
1272
- count.ok++;
1273
- if (meta["provenance"] === "override")
1274
- count.override++;
1275
- else if (meta["provenance"] === "recovered")
1276
- count.recovered++;
1277
969
  }
1278
- const completedEpisodes = [];
970
+ const lines = [];
971
+ let completeEpisodes = 0;
972
+ const errorEpisodes = [];
1279
973
  for (const ep of episodes) {
1280
- const meta = readUnitMeta(episodeMetaPath(episodeResultsDir, ep));
1281
- if (meta && meta["status"] === "ok")
1282
- completedEpisodes.push(Number(ep["episode"]));
1283
- }
1284
- // Held out = episodes with at least one terminal batch and no episode result.
1285
- const completedSet = new Set(completedEpisodes);
1286
- const heldOut = new Set();
1287
- for (const batch of batches) {
1288
- const meta = readUnitMeta(batchMetaPath(batchResultsDir, batch));
1289
- const epNum = Number(batch["episode"]);
1290
- if (meta && meta["status"] === "terminal" && !completedSet.has(epNum))
1291
- heldOut.add(epNum);
974
+ const epNum = Number(ep["episode"]);
975
+ const st = byEp.get(epNum) ?? { total: 0, done: 0, error: 0, errorKeys: [], reasons: new Set() };
976
+ let label;
977
+ if (st.total > 0 && st.done === st.total) {
978
+ label = "done";
979
+ completeEpisodes++;
980
+ }
981
+ else if (st.error > 0) {
982
+ label = `ERROR (${st.done}/${st.total} done, ${st.error} err: ${[...st.reasons].join(",")}) [${st.errorKeys.join(",")}]`;
983
+ errorEpisodes.push(epNum);
984
+ }
985
+ else {
986
+ label = `pending (${st.done}/${st.total} done)`;
987
+ }
988
+ lines.push(`${episodeResultKey(ep)} ${label}`);
1292
989
  }
1293
- const heldOutEpisodes = [...heldOut].sort((a, b) => a - b);
1294
990
  const report = {
1295
991
  title: "DIRECT STATUS",
1296
992
  result: [
1297
- `episodes: ${completedEpisodes.length}/${episodes.length} complete`,
1298
- `batches: ${count.ok}/${batches.length} ok (override ${count.override}, recovered ${count.recovered})`,
1299
- `terminal batches: ${count.terminal}`,
1300
- `pending batches: ${count.missing}`,
1301
- `held out episodes: ${heldOutEpisodes.length === 0 ? "-" : heldOutEpisodes.join(", ")}`,
993
+ `episodes: ${completeEpisodes}/${episodes.length} done`,
994
+ `batches: ${doneTotal}/${batches.length} done, ${errorTotal} error`,
995
+ `error episodes: ${errorEpisodes.length === 0 ? "-" : errorEpisodes.join(", ")}`,
996
+ ...lines,
1302
997
  ],
1303
- artifacts: [batchResultsDir, episodeResultsDir, path.join(dd, "run_state.json")],
1304
- next: heldOutEpisodes.length > 0
1305
- ? ["Override held-out episodes with direct override, or export 32/33 with direct export --allow-incomplete."]
1306
- : ["All units accounted for."],
998
+ artifacts: [batchResultsDir, path.join(dd, "episode_results"), path.join(dd, "run_state.json")],
999
+ next: errorEpisodes.length > 0
1000
+ ? [`Re-run an errored episode with: direct init --episodes ${errorEpisodes[0]} (or --batches <key>, or --retry-errors).`]
1001
+ : ["All batches accounted for."],
1307
1002
  };
1308
1003
  return [report, EXIT_OK];
1309
1004
  }