@lingjingai/scriptctl 0.11.4 → 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,10 +1,8 @@
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, PARSE_MD_SPEC, REVIEW_TARGETS, SUPPORTED_EXTS, deletePath, deleteTree, directDir, exists, fmtId, readJson, readText, sha256Text, writeJson, } from "../common.js";
4
- import { compactBatchResult, compactEpisodeResult, buildBatchPlan, buildEpisodePlan, enrichEpisodePlanTitles, extractBatchWithRecovery, mergeEpisodeResults, normalizeEpisodeResult, normalizeInt, parseAssetDoc, parseMarkdownBatch, 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
- import { ScriptOutputApiError } from "../infra/script-output-store.js";
7
- import { apiErrorToCli, currentRevisionOrZero, scriptOutputClient, sortDeep } from "./script.js";
8
6
  import { makeProvider } from "../infra/providers.js";
9
7
  import { makeSourceManifest, prepareSource, } from "../infra/converters.js";
10
8
  function strOf(v) {
@@ -25,6 +23,12 @@ function checkpointTimestamp() {
25
23
  const now = new Date();
26
24
  return now.toISOString().replace(/\.\d+Z$/, "Z");
27
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
+ }
28
32
  // ---------------------------------------------------------------------------
29
33
  // run_state.json
30
34
  // ---------------------------------------------------------------------------
@@ -56,18 +60,6 @@ export function readRunState(workspace) {
56
60
  return {};
57
61
  }
58
62
  }
59
- function failureSignature(items) {
60
- if (!isList(items))
61
- return [];
62
- const out = [];
63
- for (const item of items) {
64
- const s = strOf(item).trim();
65
- if (s)
66
- out.push(s);
67
- }
68
- out.sort();
69
- return out;
70
- }
71
63
  export function addInspectedTarget(workspace, target) {
72
64
  const state = readRunState(workspace);
73
65
  const targets = [];
@@ -149,9 +141,6 @@ function episodeErrorPath(dir, ep) {
149
141
  function episodeResultKey(ep) {
150
142
  return `ep_${pad3(Number(ep["episode"]))}`;
151
143
  }
152
- function episodeResultsIndexPath(dir) {
153
- return path.join(dir, "index.json");
154
- }
155
144
  function batchResultKey(batch) {
156
145
  const bid = strOf(batch["batch_id"]).trim();
157
146
  if (bid)
@@ -167,9 +156,6 @@ function batchMarkdownPath(dir, batch) {
167
156
  function batchErrorPath(dir, batch) {
168
157
  return path.join(dir, `${batchResultKey(batch)}.error.json`);
169
158
  }
170
- function batchResultsIndexPath(dir) {
171
- return path.join(dir, "index.json");
172
- }
173
159
  function persistBatchResult(dir, batch, result) {
174
160
  const rawMd = result["_raw_markdown"];
175
161
  delete result["_raw_markdown"];
@@ -183,263 +169,55 @@ function persistBatchResult(dir, batch, result) {
183
169
  deletePath(mdPath);
184
170
  }
185
171
  }
186
- function readBatchResultsIndex(dir) {
187
- const p = batchResultsIndexPath(dir);
188
- if (!exists(p))
189
- return { version: 1, batches: {} };
190
- let data;
191
- try {
192
- data = readJson(p);
193
- }
194
- catch {
195
- return { version: 1, batches: {} };
196
- }
197
- if (!isDict(data))
198
- return { version: 1, batches: {} };
199
- if (!isDict(data["batches"]))
200
- data["batches"] = {};
201
- if (!("version" in data))
202
- data["version"] = 1;
203
- return data;
204
- }
205
- function writeBatchResultsIndex(dir, index) {
206
- writeJson(batchResultsIndexPath(dir), index);
207
- }
208
- function updateBatchResultMetadata(dir, batch, providerName, model) {
209
- const index = readBatchResultsIndex(dir);
210
- const batches = index["batches"] ?? {};
211
- batches[batchResultKey(batch)] = {
212
- episode: Number(batch["episode"]),
213
- part: Number(batch["part"]),
214
- provider: providerName,
215
- model,
216
- extracted_at: checkpointTimestamp(),
217
- };
218
- index["batches"] = batches;
219
- writeBatchResultsIndex(dir, index);
220
- }
221
- function removeBatchResultMetadata(dir, batch) {
222
- const index = readBatchResultsIndex(dir);
223
- const batches = index["batches"] ?? {};
224
- const key = batchResultKey(batch);
225
- if (key in batches) {
226
- delete batches[key];
227
- index["batches"] = batches;
228
- writeBatchResultsIndex(dir, index);
229
- }
230
- }
231
- function readEpisodeResultsIndex(dir) {
232
- const p = episodeResultsIndexPath(dir);
233
- if (!exists(p))
234
- return { version: 1, episodes: {} };
235
- let data;
236
- try {
237
- data = readJson(p);
238
- }
239
- catch {
240
- return { version: 1, episodes: {} };
241
- }
242
- if (!isDict(data))
243
- return { version: 1, episodes: {} };
244
- if (!isDict(data["episodes"]))
245
- data["episodes"] = {};
246
- if (!("version" in data))
247
- data["version"] = 1;
248
- return data;
249
- }
250
- function writeEpisodeResultsIndex(dir, index) {
251
- writeJson(episodeResultsIndexPath(dir), index);
252
- }
253
- function updateEpisodeResultMetadata(dir, ep, providerName, model) {
254
- const index = readEpisodeResultsIndex(dir);
255
- const episodes = index["episodes"] ?? {};
256
- episodes[episodeResultKey(ep)] = {
257
- provider: providerName,
258
- model,
259
- extracted_at: checkpointTimestamp(),
260
- };
261
- index["episodes"] = episodes;
262
- writeEpisodeResultsIndex(dir, index);
263
- }
264
- function removeEpisodeResultMetadata(dir, ep) {
265
- const index = readEpisodeResultsIndex(dir);
266
- const episodes = index["episodes"] ?? {};
267
- const key = episodeResultKey(ep);
268
- if (key in episodes) {
269
- delete episodes[key];
270
- index["episodes"] = episodes;
271
- writeEpisodeResultsIndex(dir, index);
272
- }
273
- }
274
- function compactResultHasMultiRefs(data) {
275
- for (const scene of asList(data["sc"])) {
276
- if (!isDict(scene))
277
- continue;
278
- for (const action of asList(scene["a"])) {
279
- if (!isDict(action))
280
- continue;
281
- const refs = action["r"];
282
- if (isList(refs) && refs.length > 1)
283
- return true;
284
- }
285
- }
286
- return false;
287
- }
288
- export function initCheckpoint(sourceText, plan) {
289
- const planText = JSON.stringify(plan, checkpointReplacer());
290
- return {
291
- contract_version: DIRECT_CONTRACT_VERSION,
292
- source_sha256: sha256Text(sourceText),
293
- episode_plan_sha256: sha256Text(planText),
294
- total_episodes: Number(plan["total_episodes"] ?? asList(plan["episodes"]).length),
295
- };
296
- }
297
- export function initBatchCheckpoint(sourceText, batchPlan) {
298
- const planText = JSON.stringify(batchPlan, checkpointReplacer());
299
- return {
300
- contract_version: DIRECT_CONTRACT_VERSION,
301
- source_sha256: sha256Text(sourceText),
302
- batch_plan_sha256: sha256Text(planText),
303
- total_batches: Number(batchPlan["total_batches"] ?? asList(batchPlan["batches"]).length),
304
- };
305
- }
306
- // Title fields are LLM-mutated downstream by enrichEpisodePlanTitles, so they
307
- // must be excluded from checkpoint hashes — otherwise every rerun gets a fresh
308
- // SHA, the previous checkpoint never matches, and the whole batch pipeline
309
- // re-extracts from scratch.
310
- const CHECKPOINT_UNSTABLE_KEYS = new Set(["title", "generated_title", "title_status", "title_source"]);
311
- function checkpointReplacer() {
312
- // Python's json.dumps(sort_keys=True) sorts keys recursively. Replicate by walking and sorting.
313
- return function (key, value) {
314
- if (CHECKPOINT_UNSTABLE_KEYS.has(key))
315
- return undefined;
316
- if (isDict(value)) {
317
- const sorted = {};
318
- for (const k of Object.keys(value).sort()) {
319
- if (CHECKPOINT_UNSTABLE_KEYS.has(k))
320
- continue;
321
- sorted[k] = value[k];
322
- }
323
- return sorted;
324
- }
325
- return value;
326
- };
327
- }
328
- function checkpointSourceMatches(previous, current) {
329
- if (!previous || Object.keys(previous).length === 0)
330
- return false;
331
- const keys = ["contract_version", "source_sha256", "episode_plan_sha256", "total_episodes"];
332
- return keys.every((k) => previous[k] === current[k]);
333
- }
334
- function batchCheckpointMatches(previous, current) {
335
- if (!previous || Object.keys(previous).length === 0)
336
- return false;
337
- const keys = ["contract_version", "source_sha256", "batch_plan_sha256", "total_batches"];
338
- return keys.every((k) => previous[k] === current[k]);
339
- }
340
- function resetInitOutputs(dd) {
341
- for (const dirname of ["episode_results", "batch_results"]) {
342
- const target = path.join(dd, dirname);
343
- if (exists(target))
344
- deleteTree(target);
345
- }
346
- for (const name of ["script.initial.json", "validation.json", "batch_plan.json", "asset_curation.json", "asset_metadata.json"]) {
347
- const p = path.join(dd, name);
348
- if (exists(p))
349
- deletePath(p);
350
- }
351
- }
352
- function resetBatchOutputs(dd) {
353
- const batchResultsDir = path.join(dd, "batch_results");
354
- if (exists(batchResultsDir))
355
- deleteTree(batchResultsDir);
356
- }
357
- function loadCheckpointedEpisode(sourceText, episodeResultsDir, ep, providerName, model, previousProvider) {
358
- const p = episodeResultPath(episodeResultsDir, ep);
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);
359
185
  if (!exists(p))
360
186
  return null;
361
- let result;
362
187
  try {
363
- const data = readJson(p);
364
- const metadata = isDict(data["_scriptctl"]) ? data["_scriptctl"] : {};
365
- const index = readEpisodeResultsIndex(episodeResultsDir);
366
- let indexEntry = {};
367
- const eps = index["episodes"];
368
- if (isDict(eps)) {
369
- const entry = eps[episodeResultKey(ep)];
370
- if (isDict(entry))
371
- indexEntry = entry;
372
- }
373
- const resultProvider = strOf(metadata["provider"] || indexEntry["provider"] || previousProvider).trim();
374
- if (providerName && resultProvider && resultProvider !== providerName) {
375
- throw new Error(`checkpoint provider mismatch: ${resultProvider} != ${providerName}`);
376
- }
377
- result = normalizeEpisodeResult(data, ep);
378
- validateEpisodeExtractionQuality(sourceText, ep, result);
379
- if (!("sc" in data) || ["episode", "title", "source_span", "_scriptctl"].some((k) => k in data)) {
380
- writeJson(p, compactEpisodeResult(result));
381
- if (providerName && model)
382
- updateEpisodeResultMetadata(episodeResultsDir, ep, providerName, model);
383
- }
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;
384
193
  }
385
194
  catch {
386
- try {
387
- deletePath(p);
388
- }
389
- catch {
390
- // ignore
391
- }
392
- removeEpisodeResultMetadata(episodeResultsDir, ep);
393
195
  return null;
394
196
  }
395
- if (Number(result["episode"] ?? 0) !== Number(ep["episode"]))
396
- return null;
397
- if (JSON.stringify(result["source_span"]) !== JSON.stringify(ep["source_span"]))
398
- return null;
399
- return result;
400
197
  }
401
- function loadCheckpointedBatch(sourceText, batchResultsDir, batch, providerName, model, previousProvider) {
402
- const p = batchResultPath(batchResultsDir, batch);
403
- if (!exists(p))
404
- return null;
405
- let result;
406
- try {
407
- const data = readJson(p);
408
- const index = readBatchResultsIndex(batchResultsDir);
409
- let indexEntry = {};
410
- const batches = index["batches"];
411
- if (isDict(batches)) {
412
- const entry = batches[batchResultKey(batch)];
413
- if (isDict(entry))
414
- indexEntry = entry;
415
- }
416
- const resultProvider = strOf(indexEntry["provider"] || previousProvider).trim();
417
- if (providerName && resultProvider && resultProvider !== providerName) {
418
- throw new Error(`checkpoint provider mismatch: ${resultProvider} != ${providerName}`);
419
- }
420
- result = normalizeEpisodeResult(data, batch);
421
- validateBatchExtractionQuality(sourceText, batch, result);
422
- if (!("sc" in data) || compactResultHasMultiRefs(data) || ["episode", "title", "source_span", "_scriptctl"].some((k) => k in data)) {
423
- persistBatchResult(batchResultsDir, batch, result);
424
- if (providerName && model)
425
- updateBatchResultMetadata(batchResultsDir, batch, providerName, model);
426
- }
427
- }
428
- catch {
429
- try {
430
- deletePath(p);
198
+ // Delete result/meta/error/markdown files whose unit key is no longer in the
199
+ // current plan (e.g. the source shed an episode). Pure function of the plan —
200
+ // it never inspects hashes, content, or run_state, so it can only remove units
201
+ // the plan no longer references. Also retires the legacy v3 `index.json`.
202
+ function gcOrphanUnits(dir, liveKeys) {
203
+ if (!exists(dir))
204
+ return [];
205
+ const removed = [];
206
+ for (const name of fs.readdirSync(dir)) {
207
+ if (name === "index.json") {
208
+ deletePath(path.join(dir, name));
209
+ removed.push(name);
210
+ continue;
431
211
  }
432
- catch {
433
- // ignore
212
+ const key = name.replace(/\.(meta\.json|error\.json|json|md)$/, "");
213
+ if (key === name)
214
+ continue; // not a recognized unit artifact
215
+ if (!liveKeys.has(key)) {
216
+ deletePath(path.join(dir, name));
217
+ removed.push(name);
434
218
  }
435
- removeBatchResultMetadata(batchResultsDir, batch);
436
- return null;
437
219
  }
438
- if (Number(result["episode"] ?? 0) !== Number(batch["episode"]))
439
- return null;
440
- if (JSON.stringify(result["source_span"]) !== JSON.stringify(batch["source_span"]))
441
- return null;
442
- return result;
220
+ return removed;
443
221
  }
444
222
  function mergeScene(target, source) {
445
223
  if ((target["location_name"] === "" || target["location_name"] === "未知场景" || target["location_name"] === null || target["location_name"] === undefined) &&
@@ -532,31 +310,10 @@ async function providerExtractAssetCurationLocal(provider, sourceText, script) {
532
310
  }
533
311
  return {};
534
312
  }
535
- function writeEpisodeFailure(dir, ep, exc) {
536
- const err = exc;
537
- const error = {
538
- episode: Number(ep["episode"]),
539
- title: ep["title"],
540
- source_span: ep["source_span"],
541
- error_type: err?.name || "Error",
542
- message: (err?.message || err?.name || "Error").slice(0, 500),
543
- failed_at: checkpointTimestamp(),
544
- };
545
- if (exc instanceof CliError) {
546
- if (exc.required.length > 0)
547
- error["required"] = exc.required;
548
- if (exc.received.length > 0)
549
- error["received"] = exc.received;
550
- if (exc.nextSteps.length > 0)
551
- error["next"] = exc.nextSteps;
552
- }
553
- const resultPath = episodeResultPath(dir, ep);
554
- if (exists(resultPath))
555
- deletePath(resultPath);
556
- removeEpisodeResultMetadata(dir, ep);
557
- writeJson(episodeErrorPath(dir, ep), error);
558
- return error;
559
- }
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.
560
317
  function writeBatchFailure(dir, batch, exc) {
561
318
  const err = exc;
562
319
  const error = {
@@ -570,6 +327,8 @@ function writeBatchFailure(dir, batch, exc) {
570
327
  failed_at: checkpointTimestamp(),
571
328
  };
572
329
  if (exc instanceof CliError) {
330
+ if (exc.errorCode)
331
+ error["error_code"] = exc.errorCode;
573
332
  if (exc.required.length > 0)
574
333
  error["required"] = exc.required;
575
334
  if (exc.received.length > 0)
@@ -580,7 +339,6 @@ function writeBatchFailure(dir, batch, exc) {
580
339
  const resultPath = batchResultPath(dir, batch);
581
340
  if (exists(resultPath))
582
341
  deletePath(resultPath);
583
- removeBatchResultMetadata(dir, batch);
584
342
  writeJson(batchErrorPath(dir, batch), error);
585
343
  return error;
586
344
  }
@@ -635,6 +393,14 @@ export async function commandInit(opts) {
635
393
  const workspace = strOf(opts["workspace_path"] || "workspace");
636
394
  const providerName = strOf(opts["provider"] || DEFAULT_PROVIDER);
637
395
  const model = strOf(opts["model"] || process.env.SCRIPTCTL_ANTHROPIC_MODEL || DEFAULT_MODEL);
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"]);
638
404
  let concurrency;
639
405
  try {
640
406
  concurrency = parseInt(strOf(opts["concurrency"] || DEFAULT_CONCURRENCY), 10);
@@ -719,7 +485,6 @@ export async function commandInit(opts) {
719
485
  }
720
486
  const dd = directDir(workspace);
721
487
  fs.mkdirSync(dd, { recursive: true });
722
- const previousStateBeforeInit = readRunState(workspace);
723
488
  updateRunState(workspace, {
724
489
  status: "init_running",
725
490
  command: "direct init",
@@ -832,17 +597,6 @@ export async function commandInit(opts) {
832
597
  nextSteps: ["Inspect workspace/source.txt and episode_plan.json, then rerun init."],
833
598
  });
834
599
  }
835
- const checkpoint = initCheckpoint(sourceText, plan);
836
- const batchCheckpoint = initBatchCheckpoint(sourceText, batchPlan);
837
- const previousState = previousStateBeforeInit;
838
- const previousCheckpoint = isDict(previousState["checkpoint"]) ? previousState["checkpoint"] : {};
839
- const previousBatchCheckpoint = isDict(previousState["batch_checkpoint"]) ? previousState["batch_checkpoint"] : {};
840
- const checkpointReused = checkpointSourceMatches(previousCheckpoint, checkpoint);
841
- const batchCheckpointReused = checkpointReused && batchCheckpointMatches(previousBatchCheckpoint, batchCheckpoint);
842
- if (!checkpointReused)
843
- resetInitOutputs(dd);
844
- else if (!batchCheckpointReused)
845
- resetBatchOutputs(dd);
846
600
  writeJson(path.join(dd, "source_manifest.json"), manifest);
847
601
  writeJson(path.join(dd, "episode_plan.json"), plan);
848
602
  writeJson(path.join(dd, "batch_plan.json"), batchPlan);
@@ -850,24 +604,12 @@ export async function commandInit(opts) {
850
604
  const batchResultsDir = path.join(dd, "batch_results");
851
605
  fs.mkdirSync(episodeResultsDir, { recursive: true });
852
606
  fs.mkdirSync(batchResultsDir, { recursive: true });
853
- updateRunState(workspace, {
854
- status: "init_running",
855
- init_stage: "batch_extract",
856
- checkpoint,
857
- batch_checkpoint: batchCheckpoint,
858
- checkpoint_reused: checkpointReused,
859
- batch_checkpoint_reused: batchCheckpointReused,
860
- batch_mode: batchMode,
861
- batch_target_lines: batchTargetLines,
862
- batch_max_chars: batchMaxChars,
863
- batch_min_lines: batchMinLines,
864
- episode_total: asList(plan["episodes"]).length,
865
- batch_total: asList(batchPlan["batches"]).length,
866
- });
867
- const results = [];
868
- const skipped = [];
869
- let skippedEpisodeBatchCount = 0;
870
- const pendingBatches = [];
607
+ // Non-destructive GC: drop result/meta/error/md files for units the current
608
+ // plan no longer references (e.g. the source shed an episode). Pure function
609
+ // of the plan — it never touches a unit the plan still references, and retires
610
+ // the legacy v3 index.json. There is no whole-directory reset any more.
611
+ gcOrphanUnits(episodeResultsDir, new Set(asList(plan["episodes"]).map((ep) => episodeResultKey(ep))));
612
+ gcOrphanUnits(batchResultsDir, new Set(asList(batchPlan["batches"]).map((b) => batchResultKey(b))));
871
613
  const batchesByEpisode = new Map();
872
614
  for (const batch of asList(batchPlan["batches"])) {
873
615
  const epNum = Number(batch["episode"]);
@@ -875,65 +617,55 @@ export async function commandInit(opts) {
875
617
  batchesByEpisode.set(epNum, []);
876
618
  batchesByEpisode.get(epNum).push(batch);
877
619
  }
878
- const previousProvider = strOf(previousState["provider"]).trim() || null;
879
- for (const episode of asList(plan["episodes"])) {
880
- const cached = checkpointReused
881
- ? loadCheckpointedEpisode(sourceText, episodeResultsDir, episode, providerName, model, previousProvider)
882
- : null;
883
- if (cached !== null) {
884
- results.push(cached);
885
- skipped.push(Number(episode["episode"]));
886
- const cachedBatches = batchesByEpisode.get(Number(episode["episode"])) ?? [];
887
- skippedEpisodeBatchCount += cachedBatches.length;
888
- for (const cachedBatch of cachedBatches) {
889
- if (!exists(batchResultPath(batchResultsDir, cachedBatch))) {
890
- const backfilled = recoverBatchFromSource(sourceText, cachedBatch);
891
- persistBatchResult(batchResultsDir, cachedBatch, backfilled);
892
- updateBatchResultMetadata(batchResultsDir, cachedBatch, providerName, model);
893
- }
894
- const errorPath = batchErrorPath(batchResultsDir, cachedBatch);
895
- if (exists(errorPath))
896
- deletePath(errorPath);
897
- }
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.
625
+ const pending = [];
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);
898
640
  }
899
- else {
900
- pendingBatches.push(...(batchesByEpisode.get(Number(episode["episode"])) ?? []));
641
+ else if (exists(resultPath)) {
642
+ reusedBatchCount++;
901
643
  }
902
- }
903
- const batchResults = [];
904
- const skippedBatches = [];
905
- const pending = [];
906
- for (const batch of pendingBatches) {
907
- const cachedBatch = batchCheckpointReused
908
- ? loadCheckpointedBatch(sourceText, batchResultsDir, batch, providerName, model, previousProvider)
909
- : null;
910
- if (cachedBatch !== null) {
911
- cachedBatch["_batch_id"] = batchResultKey(batch);
912
- cachedBatch["_batch_part"] = Number(batch["part"]);
913
- cachedBatch["_starts_inside_scene"] = Boolean(batch["starts_inside_scene"]);
914
- batchResults.push(cachedBatch);
915
- skippedBatches.push(batchResultKey(batch));
644
+ else if (exists(errorPath)) {
645
+ stickyErrorCount++; // tried and failed; left alone until explicitly re-run
916
646
  }
917
647
  else {
918
- pending.push(batch);
648
+ pending.push(batch); // never attempted
919
649
  }
920
650
  }
921
- const failures = [];
922
- const outcomes = await pMapWithConcurrency(pending, concurrency, async (batch) => {
923
- 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,
924
660
  });
661
+ const failures = [];
662
+ const outcomes = await pMapWithConcurrency(pending, concurrency, async (batch) => extractBatchWithRecovery(provider, sourceText, batch));
925
663
  for (let i = 0; i < outcomes.length; i++) {
926
664
  const outcome = outcomes[i];
927
665
  const batch = pending[i];
928
- const errorPath = batchErrorPath(batchResultsDir, batch);
929
666
  if (outcome.ok) {
930
- const result = outcome.value;
931
- result["_batch_id"] = batchResultKey(batch);
932
- result["_batch_part"] = Number(batch["part"]);
933
- result["_starts_inside_scene"] = Boolean(batch["starts_inside_scene"]);
934
- batchResults.push(result);
935
- persistBatchResult(batchResultsDir, batch, result);
936
- updateBatchResultMetadata(batchResultsDir, batch, providerName, model);
667
+ persistBatchResult(batchResultsDir, batch, outcome.value);
668
+ const errorPath = batchErrorPath(batchResultsDir, batch);
937
669
  if (exists(errorPath))
938
670
  deletePath(errorPath);
939
671
  }
@@ -941,185 +673,93 @@ export async function commandInit(opts) {
941
673
  failures.push(writeBatchFailure(batchResultsDir, batch, outcome.error));
942
674
  }
943
675
  }
944
- results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
945
- batchResults.sort((a, b) => {
946
- const ea = Number(a["episode"] ?? 0);
947
- const eb = Number(b["episode"] ?? 0);
948
- if (ea !== eb)
949
- return ea - eb;
950
- return Number(a["_batch_part"] ?? 0) - Number(b["_batch_part"] ?? 0);
951
- });
952
- failures.sort((a, b) => {
953
- const ea = Number(a["episode"] ?? 0);
954
- const eb = Number(b["episode"] ?? 0);
955
- if (ea !== eb)
956
- return ea - eb;
957
- return Number(a["part"] ?? 0) - Number(b["part"] ?? 0);
958
- });
959
- const completedBatches = skippedEpisodeBatchCount + batchResults.length;
960
- if (failures.length > 0) {
961
- const failedEpisodes = [...new Set(failures.map((it) => Number(it["episode"])))].sort((a, b) => a - b);
962
- const failedBatches = failures.map((it) => strOf(it["batch_id"]));
963
- const currentFailureSignature = failureSignature(failedBatches);
964
- const previousFailureSignature = failureSignature(previousState["failed_batches"]);
965
- const sameFailuresRepeated = checkpointReused &&
966
- batchCheckpointReused &&
967
- currentFailureSignature.length > 0 &&
968
- currentFailureSignature.length === previousFailureSignature.length &&
969
- currentFailureSignature.every((v, idx) => v === previousFailureSignature[idx]) &&
970
- ["init_incomplete", "init_stalled"].includes(strOf(previousState["status"]));
971
- const previousFailureStreak = normalizeInt(previousState["failure_streak"], 0);
972
- const failureStreak = sameFailuresRepeated ? previousFailureStreak + 1 : 1;
973
- const failureTitle = sameFailuresRepeated
974
- ? "INIT STALLED: Same batches keep failing"
975
- : "INIT INCOMPLETE: Batch extraction failed";
976
- const nextSteps = sameFailuresRepeated
977
- ? [
978
- "Run direct inspect --target issue to read failed batch details.",
979
- "Do not rerun the same init command again until source, batch options, provider, or failed content has changed.",
980
- ]
981
- : [
982
- "Run direct inspect --target issue to review failed batches.",
983
- "Rerun the same init once if failures look transient; completed checkpoints will be reused.",
984
- ];
985
- const failedEpisodeSet = new Set(failedEpisodes);
986
- const skippedSet = new Set(skipped);
987
- const batchResultsByEpisode = new Map();
988
- for (const result of batchResults) {
989
- const ep = Number(result["episode"] ?? 0);
990
- if (!batchResultsByEpisode.has(ep))
991
- batchResultsByEpisode.set(ep, []);
992
- batchResultsByEpisode.get(ep).push(result);
993
- }
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 = [];
683
+ try {
994
684
  for (const episode of asList(plan["episodes"])) {
995
685
  const episodeNum = Number(episode["episode"]);
996
- if (skippedSet.has(episodeNum) || failedEpisodeSet.has(episodeNum))
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);
997
690
  continue;
998
- const expectedBatches = (batchesByEpisode.get(episodeNum) ?? []).length;
999
- if (expectedBatches && (batchResultsByEpisode.get(episodeNum) ?? []).length === expectedBatches) {
1000
- const result = mergeBatchResultsForEpisode(episode, batchResultsByEpisode.get(episodeNum) ?? []);
1001
- validateEpisodeExtractionQuality(sourceText, episode, result);
1002
- results.push(result);
1003
- writeJson(episodeResultPath(episodeResultsDir, episode), compactEpisodeResult(result));
1004
- updateEpisodeResultMetadata(episodeResultsDir, episode, providerName, model);
1005
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);
1006
699
  }
700
+ }
701
+ catch (exc) {
702
+ const e = exc;
703
+ throw initFailedReport(workspace, {
704
+ title: "INIT FAILED: Episode merge failed",
705
+ stage: "episode_merge",
706
+ required: ["batch_results/*.json that can merge into episode_results/*.json"],
707
+ received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
708
+ nextSteps: ["Rerun init; completed batches are reused and episode merge will retry."],
709
+ });
710
+ }
711
+ results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
712
+ incompleteEpisodes.sort((a, b) => a - b);
713
+ if (incompleteEpisodes.length > 0) {
1007
714
  updateRunState(workspace, {
1008
- status: sameFailuresRepeated ? "init_stalled" : "init_incomplete",
715
+ status: "init_incomplete",
1009
716
  init_stage: "batch_extract",
1010
- checkpoint,
1011
- batch_checkpoint: batchCheckpoint,
1012
- episode_total: asList(plan["episodes"]).length,
717
+ episode_total: totalEpisodes,
1013
718
  episode_completed: results.length,
1014
- episode_reused: skipped.length,
1015
- episode_failed: failedEpisodes.length,
1016
- failed_episodes: failedEpisodes,
1017
- batch_total: asList(batchPlan["batches"]).length,
1018
- batch_completed: completedBatches,
1019
- batch_reused: skippedEpisodeBatchCount + skippedBatches.length,
1020
- batch_failed: failures.length,
1021
- failed_batches: failedBatches,
1022
- failure_signature: currentFailureSignature,
1023
- failure_streak: failureStreak,
1024
- last_error: { title: failureTitle, failed_at: checkpointTimestamp() },
1025
- exportable: false,
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() },
1026
724
  });
1027
- const issues = failures.slice(0, 5).map((it) => `${it["batch_id"]} episode ${it["episode"]} part ${it["part"]}: ${it["error_type"]} - ${it["message"]}`);
1028
725
  const report = {
1029
- title: failureTitle,
726
+ title: "INIT INCOMPLETE: Some episodes have unextracted batches",
1030
727
  result: [
1031
- `episodes total: ${asList(plan["episodes"]).length}`,
1032
- `completed: ${results.length}`,
1033
- `reused: ${skipped.length}`,
1034
- `failed episodes: ${failedEpisodes.length}`,
1035
- `batches: ${completedBatches}/${asList(batchPlan["batches"]).length} completed, ${failures.length} failed`,
728
+ `episodes: ${results.length}/${totalEpisodes} complete`,
729
+ `incomplete episodes: ${incompleteEpisodes.join(", ")}`,
730
+ `batches: ${doneBatches}/${totalBatches} done, ${erroredBatches} error`,
1036
731
  `provider: ${providerName}`,
1037
732
  ],
1038
733
  artifacts: [
1039
734
  path.join(workspace, "source.txt"),
1040
- path.join(dd, "source_manifest.json"),
1041
735
  path.join(dd, "episode_plan.json"),
1042
736
  path.join(dd, "batch_plan.json"),
1043
737
  batchResultsDir,
1044
738
  episodeResultsDir,
1045
739
  path.join(dd, "run_state.json"),
1046
740
  ],
1047
- issues,
1048
- next: nextSteps,
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
+ ],
1049
746
  };
1050
747
  return [report, EXIT_RUNTIME];
1051
748
  }
1052
749
  updateRunState(workspace, {
1053
750
  status: "init_running",
1054
751
  init_stage: "episode_merge",
1055
- checkpoint,
1056
- batch_checkpoint: batchCheckpoint,
1057
- episode_total: asList(plan["episodes"]).length,
752
+ episode_total: totalEpisodes,
1058
753
  episode_completed: results.length,
1059
- episode_reused: skipped.length,
1060
- episode_failed: 0,
1061
- failed_episodes: [],
1062
- batch_total: asList(batchPlan["batches"]).length,
1063
- batch_completed: completedBatches,
1064
- batch_reused: skippedEpisodeBatchCount + skippedBatches.length,
1065
- batch_failed: 0,
1066
- failed_batches: [],
1067
- failure_signature: [],
1068
- failure_streak: 0,
754
+ incomplete_episodes: [],
755
+ batch_total: totalBatches,
756
+ batch_completed: doneBatches,
757
+ batch_failed: erroredBatches,
1069
758
  last_error: null,
1070
759
  });
1071
- for (const dir of [batchResultsDir, episodeResultsDir]) {
1072
- if (!exists(dir))
1073
- continue;
1074
- for (const name of fs.readdirSync(dir)) {
1075
- if (name.endsWith(".error.json")) {
1076
- try {
1077
- deletePath(path.join(dir, name));
1078
- }
1079
- catch {
1080
- // ignore
1081
- }
1082
- }
1083
- }
1084
- }
1085
- try {
1086
- const batchResultsByEpisode = new Map();
1087
- for (const result of batchResults) {
1088
- const ep = Number(result["episode"] ?? 0);
1089
- if (!batchResultsByEpisode.has(ep))
1090
- batchResultsByEpisode.set(ep, []);
1091
- batchResultsByEpisode.get(ep).push(result);
1092
- }
1093
- const skippedSet = new Set(skipped);
1094
- for (const episode of asList(plan["episodes"])) {
1095
- const episodeNum = Number(episode["episode"]);
1096
- if (skippedSet.has(episodeNum))
1097
- continue;
1098
- const result = mergeBatchResultsForEpisode(episode, batchResultsByEpisode.get(episodeNum) ?? []);
1099
- validateEpisodeExtractionQuality(sourceText, episode, result);
1100
- results.push(result);
1101
- writeJson(episodeResultPath(episodeResultsDir, episode), compactEpisodeResult(result));
1102
- updateEpisodeResultMetadata(episodeResultsDir, episode, providerName, model);
1103
- const errorPath = episodeErrorPath(episodeResultsDir, episode);
1104
- if (exists(errorPath))
1105
- deletePath(errorPath);
1106
- }
1107
- }
1108
- catch (exc) {
1109
- const e = exc;
1110
- throw initFailedReport(workspace, {
1111
- title: "INIT FAILED: Episode merge failed",
1112
- stage: "episode_merge",
1113
- required: ["complete batch_results/*.json that can merge into episode_results/*.json"],
1114
- received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1115
- nextSteps: ["Rerun init; completed batch checkpoints will be reused and episode merge will retry."],
1116
- updates: { checkpoint, batch_checkpoint: batchCheckpoint, batch_completed: completedBatches },
1117
- });
1118
- }
1119
- results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
1120
760
  let script;
1121
761
  try {
1122
- updateRunState(workspace, { status: "init_running", init_stage: "script_merge", checkpoint, batch_checkpoint: batchCheckpoint });
762
+ updateRunState(workspace, { status: "init_running", init_stage: "script_merge" });
1123
763
  script = mergeEpisodeResults(results, strOf(info["projectName"]) || path.basename(source, path.extname(source)));
1124
764
  }
1125
765
  catch (exc) {
@@ -1130,11 +770,11 @@ export async function commandInit(opts) {
1130
770
  required: ["complete episode_results/*.json"],
1131
771
  received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1132
772
  nextSteps: ["Rerun init; completed episode extraction checkpoints will be reused and merge will retry."],
1133
- updates: { checkpoint, batch_checkpoint: batchCheckpoint, episode_completed: results.length },
773
+ updates: { episode_completed: results.length },
1134
774
  });
1135
775
  }
1136
776
  try {
1137
- updateRunState(workspace, { status: "init_running", init_stage: "asset_curation", checkpoint, batch_checkpoint: batchCheckpoint });
777
+ updateRunState(workspace, { status: "init_running", init_stage: "asset_curation" });
1138
778
  const rawCuration = await providerExtractAssetCurationLocal(provider, sourceText, script);
1139
779
  const curation = curateScriptAssets(script, rawCuration);
1140
780
  writeJson(path.join(dd, "asset_curation.json"), curation);
@@ -1148,7 +788,7 @@ export async function commandInit(opts) {
1148
788
  required: exc.required.length > 0 ? exc.required : ["asset curation JSON matching final script contract"],
1149
789
  received: exc.received.length > 0 ? exc.received : [String(exc.message).slice(0, 160)],
1150
790
  nextSteps: exc.nextSteps.length > 0 ? exc.nextSteps : ["Rerun init; extraction checkpoints will be reused and asset curation will retry."],
1151
- updates: { checkpoint, batch_checkpoint: batchCheckpoint, episode_completed: results.length },
791
+ updates: { episode_completed: results.length },
1152
792
  });
1153
793
  }
1154
794
  const e = exc;
@@ -1158,11 +798,11 @@ export async function commandInit(opts) {
1158
798
  required: ["provider location merge decisions and deterministic asset reuse curation"],
1159
799
  received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1160
800
  nextSteps: ["Rerun init; extraction checkpoints will be reused and asset curation will retry."],
1161
- updates: { checkpoint, batch_checkpoint: batchCheckpoint, episode_completed: results.length },
801
+ updates: { episode_completed: results.length },
1162
802
  });
1163
803
  }
1164
804
  try {
1165
- updateRunState(workspace, { status: "init_running", init_stage: "metadata_extract", checkpoint, batch_checkpoint: batchCheckpoint });
805
+ updateRunState(workspace, { status: "init_running", init_stage: "metadata_extract" });
1166
806
  let metadata = provider.extractMetadata ? await provider.extractMetadata(sourceText, script) : {};
1167
807
  if (!isDict(metadata))
1168
808
  metadata = {};
@@ -1178,7 +818,7 @@ export async function commandInit(opts) {
1178
818
  required: exc.required.length > 0 ? exc.required : ["metadata JSON matching final script contract"],
1179
819
  received: exc.received.length > 0 ? exc.received : [String(exc.message).slice(0, 160)],
1180
820
  nextSteps: exc.nextSteps.length > 0 ? exc.nextSteps : ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
1181
- updates: { checkpoint, batch_checkpoint: batchCheckpoint, episode_completed: results.length },
821
+ updates: { episode_completed: results.length },
1182
822
  });
1183
823
  }
1184
824
  const e = exc;
@@ -1188,12 +828,12 @@ export async function commandInit(opts) {
1188
828
  required: ["provider metadata for worldview, role_type, and asset descriptions"],
1189
829
  received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1190
830
  nextSteps: ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
1191
- updates: { checkpoint, batch_checkpoint: batchCheckpoint, episode_completed: results.length },
831
+ updates: { episode_completed: results.length },
1192
832
  });
1193
833
  }
1194
834
  const scriptPath = path.join(dd, "script.initial.json");
1195
835
  writeJson(scriptPath, script);
1196
- updateRunState(workspace, { status: "init_running", init_stage: "validate", checkpoint, batch_checkpoint: batchCheckpoint });
836
+ updateRunState(workspace, { status: "init_running", init_stage: "validate" });
1197
837
  let validation;
1198
838
  try {
1199
839
  validation = validateScript(workspace, scriptPath);
@@ -1206,7 +846,7 @@ export async function commandInit(opts) {
1206
846
  required: ["script.initial.json that can be validated"],
1207
847
  received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
1208
848
  nextSteps: ["Rerun init to retry validation, or inspect script.initial.json if the failure persists."],
1209
- updates: { checkpoint, script_path: scriptPath },
849
+ updates: { script_path: scriptPath },
1210
850
  });
1211
851
  }
1212
852
  const passed = Boolean(validation["passed"]);
@@ -1215,10 +855,6 @@ export async function commandInit(opts) {
1215
855
  status,
1216
856
  command: "direct init",
1217
857
  init_stage: "complete",
1218
- checkpoint,
1219
- batch_checkpoint: batchCheckpoint,
1220
- checkpoint_reused: checkpointReused,
1221
- batch_checkpoint_reused: batchCheckpointReused,
1222
858
  provider: providerName,
1223
859
  model,
1224
860
  concurrency,
@@ -1229,24 +865,18 @@ export async function commandInit(opts) {
1229
865
  source_path: path.resolve(source),
1230
866
  script_path: scriptPath,
1231
867
  validation_path: path.join(dd, "validation.json"),
1232
- episode_total: asList(plan["episodes"]).length,
868
+ episode_total: totalEpisodes,
1233
869
  episode_completed: results.length,
1234
- episode_reused: skipped.length,
1235
- episode_failed: 0,
1236
- failed_episodes: [],
1237
- batch_total: asList(batchPlan["batches"]).length,
1238
- batch_completed: completedBatches,
1239
- batch_reused: skippedEpisodeBatchCount + skippedBatches.length,
1240
- batch_failed: 0,
1241
- failed_batches: [],
1242
- failure_signature: [],
1243
- failure_streak: 0,
870
+ incomplete_episodes: [],
871
+ batch_total: totalBatches,
872
+ batch_completed: doneBatches,
873
+ batch_reused: reusedBatchCount,
874
+ batch_failed: erroredBatches,
1244
875
  last_error: null,
1245
876
  review_status: "pending",
1246
877
  review_missing: [...REVIEW_TARGETS],
1247
878
  inspected_targets: [],
1248
879
  patch_count: 0,
1249
- exportable: providerName !== "mock",
1250
880
  });
1251
881
  const title = passed
1252
882
  ? "INIT COMPLETE: Initial script ready"
@@ -1260,9 +890,8 @@ export async function commandInit(opts) {
1260
890
  `actions: ${stats["actions"] ?? 0}`,
1261
891
  `validation: ${passed ? "passed" : "needs repair"}`,
1262
892
  `provider: ${providerName}`,
1263
- `episode checkpoint reused: ${skipped.length}`,
1264
- `batches: ${completedBatches}/${asList(batchPlan["batches"]).length} completed`,
1265
- `batch checkpoint reused: ${skippedEpisodeBatchCount + skippedBatches.length}`,
893
+ `episodes: ${results.length}/${totalEpisodes} complete`,
894
+ `batches: ${doneBatches}/${totalBatches} done (reused ${reusedBatchCount})`,
1266
895
  "agent_review: pending",
1267
896
  ],
1268
897
  artifacts: [
@@ -1288,277 +917,102 @@ export async function commandInit(opts) {
1288
917
  };
1289
918
  return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
1290
919
  }
1291
- export function summarizeIssues(issues) {
1292
- if (issues.length === 0)
1293
- return [];
1294
- const counts = {};
1295
- for (const item of issues) {
1296
- const sev = strOf(item["severity"]);
1297
- counts[sev] = (counts[sev] ?? 0) + 1;
1298
- }
1299
- const parts = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)).map(([sev, c]) => `${sev}: ${c}`);
1300
- const first = issues[0];
1301
- return [parts.join("; "), `first: ${first["code"]} - ${first["summary"]}`];
1302
- }
1303
920
  // ---------------------------------------------------------------------------
1304
- // command_parsesubagent-authored md workspace script.initial.json
1305
- //
1306
- // Deterministic, no-LLM counterpart of `direct init`, but a DISTINCT layout from
1307
- // write/direct: the 正文 md carries only the screenplay (no asset registration at
1308
- // all), and each asset KIND is registered in its own file —
1309
- // 人物.md / 场景.md / 道具.md / 发声源.md (+ optional 梗概.md for the whole-script
1310
- // synopsis). It assembles the same script.initial.json and hands off to the
1311
- // existing direct inspect/validate/export downstream (zero changes there).
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.
1312
925
  // ---------------------------------------------------------------------------
1313
- const _EP_FILE_RE = /^ep[_-]?0*(\d+)\.(?:md|markdown)$/i;
1314
- const ASSET_DOC_SPECS = [
1315
- { kind: "actors", names: ["人物.md", "角色.md", "characters.md", "actors.md"] },
1316
- { kind: "locations", names: ["场景.md", "地点.md", "locations.md"] },
1317
- { kind: "props", names: ["道具.md", "props.md"] },
1318
- { kind: "speakers", names: ["发声源.md", "speakers.md"] },
1319
- ];
1320
- const SYNOPSIS_DOC_NAMES = ["梗概.md", "全文梗概.md", "synopsis.md"];
1321
- const META_DOC_NAMES = ["元信息.md", "meta.md"];
1322
- // Parse whole-script metadata from 元信息.md. Lines like `worldview: 现代` /
1323
- // `世界观:现代` / `- style: 都市甜宠` / `主角: 陈墨, 苏晴`.
1324
- function parseMetaDoc(text) {
1325
- const out = {};
1326
- for (const line of text.split(/\r?\n/)) {
1327
- const m = /^\s*[-*]?\s*(title|worldview|style|protagonists|标题|世界观|风格|主角|主角列表)\s*[::]\s*(.+?)\s*$/i.exec(line);
1328
- if (!m)
1329
- continue;
1330
- const key = m[1].toLowerCase();
1331
- const val = m[2].trim();
1332
- if (!val)
1333
- continue;
1334
- if (key === "title" || key === "标题")
1335
- out.title = val;
1336
- else if (key === "worldview" || key === "世界观")
1337
- out.worldview = val;
1338
- else if (key === "style" || key === "风格")
1339
- out.style = val;
1340
- else if (key === "protagonists" || key === "主角" || key === "主角列表") {
1341
- out.protagonists = val.split(/[,,、]/).map((s) => s.trim()).filter(Boolean);
1342
- }
1343
- }
1344
- return out;
1345
- }
1346
- function firstExisting(dir, names) {
1347
- for (const n of names) {
1348
- const p = path.join(dir, n);
1349
- if (exists(p) && fs.statSync(p).isFile())
1350
- return p;
1351
- }
1352
- return null;
1353
- }
1354
- function collectEpisodeMdFiles(dir) {
1355
- if (!exists(dir) || !fs.statSync(dir).isDirectory())
1356
- return [];
1357
- const out = [];
1358
- for (const name of fs.readdirSync(dir)) {
1359
- const m = _EP_FILE_RE.exec(name);
1360
- if (!m)
1361
- continue;
1362
- const full = path.join(dir, name);
1363
- if (!fs.statSync(full).isFile())
1364
- continue;
1365
- out.push({ path: full, episode: parseInt(m[1], 10) });
1366
- }
1367
- out.sort((a, b) => a.episode - b.episode);
1368
- return out;
1369
- }
1370
- export async function commandParse(opts) {
1371
- if (opts["spec"]) {
1372
- return [{ title: "PARSE SPEC: md 工作区写法", body: PARSE_MD_SPEC }, EXIT_OK];
1373
- }
926
+ export function commandStatus(opts) {
1374
927
  const workspace = strOf(opts["workspace_path"] || "workspace");
1375
- const args = asList(opts["_args"]);
1376
- const mdDir = strOf(opts["md_dir"] || args[0] || path.join(workspace, "parse"));
1377
- if (!exists(mdDir) || !fs.statSync(mdDir).isDirectory()) {
1378
- throw new CliError("PARSE BLOCKED: md workspace not found", "md workspace not found.", {
1379
- exitCode: EXIT_INPUT,
1380
- required: ["a directory with per-episode 正文 md + 人物/场景/道具/发声源 md"],
1381
- received: [mdDir],
1382
- nextSteps: ["Pass the md workspace dir: scriptctl parse <dir>. Run `scriptctl parse --spec` for the format."],
1383
- });
1384
- }
1385
- let episodesDir = strOf(opts["episodes_dir"]).trim();
1386
- if (!episodesDir) {
1387
- const sub = path.join(mdDir, "episodes");
1388
- episodesDir = exists(sub) && fs.statSync(sub).isDirectory() ? sub : mdDir;
1389
- }
1390
- const bodyFiles = collectEpisodeMdFiles(episodesDir);
1391
- if (bodyFiles.length === 0) {
1392
- throw new CliError("PARSE BLOCKED: no episode md found", "no episode md found.", {
928
+ const dd = directDir(workspace);
929
+ const episodePlanPath = path.join(dd, "episode_plan.json");
930
+ const batchPlanPath = path.join(dd, "batch_plan.json");
931
+ if (!exists(episodePlanPath) || !exists(batchPlanPath)) {
932
+ throw new CliError("STATUS BLOCKED: Plan not found", "Plan not found.", {
1393
933
  exitCode: EXIT_INPUT,
1394
- required: ["per-episode body md named like ep_001.md"],
1395
- received: [episodesDir],
1396
- nextSteps: ["Add per-episode 正文 md (ep_001.md, ep_002.md, ...). Run `scriptctl parse --spec` for the format."],
934
+ required: ["episode_plan.json and batch_plan.json"],
935
+ received: [exists(episodePlanPath) ? "episode_plan.json ok" : "episode_plan.json missing"],
936
+ nextSteps: ["Run scriptctl direct init first."],
1397
937
  });
1398
938
  }
1399
- // Each asset KIND is registered in its own file; the 正文 md carries none.
1400
- // Fold them all into one bible fragment (per-kind arrays).
1401
- const bible = { actors: [], locations: [], props: [], speakers: [], state_definitions: [] };
1402
- const assetDocsFound = [];
1403
- for (const spec of ASSET_DOC_SPECS) {
1404
- const p = firstExisting(mdDir, spec.names);
1405
- if (!p)
939
+ const episodes = asList(readJson(episodePlanPath)["episodes"]);
940
+ const batches = asList(readJson(batchPlanPath)["batches"]);
941
+ const batchResultsDir = path.join(dd, "batch_results");
942
+ const byEp = new Map();
943
+ let doneTotal = 0;
944
+ let errorTotal = 0;
945
+ for (const batch of batches) {
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++;
1406
954
  continue;
1407
- assetDocsFound.push(path.basename(p));
1408
- const parsed = parseAssetDoc(readText(p), spec.kind);
1409
- for (const key of ["actors", "locations", "props", "speakers", "state_definitions"]) {
1410
- bible[key].push(...asList(parsed[key]));
1411
- }
1412
- }
1413
- // Optional whole-script synopsis (梗概.md); strip a leading `# 梗概` header line.
1414
- let globalSynopsis = "";
1415
- const synPath = firstExisting(mdDir, SYNOPSIS_DOC_NAMES);
1416
- if (synPath)
1417
- globalSynopsis = readText(synPath).replace(/^\s*#\s+\S[^\n]*\n/, "").trim();
1418
- // Optional whole-script metadata (元信息.md): worldview / style / title.
1419
- const metaPath = firstExisting(mdDir, META_DOC_NAMES);
1420
- const meta = metaPath ? parseMetaDoc(readText(metaPath)) : {};
1421
- const results = [];
1422
- const sourceChunks = [];
1423
- for (const file of bodyFiles) {
1424
- const bodyText = readText(file.path);
1425
- sourceChunks.push(`# ep_${pad3(file.episode)}\n${bodyText.trim()}`);
1426
- try {
1427
- results.push(parseMarkdownBatch(bodyText, { episode: file.episode, part: 1 }, { fragmentMode: true }));
1428
- }
1429
- catch (exc) {
1430
- const e = exc;
1431
- throw new CliError("PARSE BLOCKED: episode md invalid", "episode md invalid.", {
1432
- exitCode: EXIT_INPUT,
1433
- required: ["per-episode 正文 md following `scriptctl parse --spec`"],
1434
- received: [`${path.basename(file.path)}: ${(e?.message ?? "").slice(0, 200)}`],
1435
- nextSteps: ["Fix the episode md and re-run parse."],
1436
- });
1437
- }
1438
- }
1439
- results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
1440
- // Fold the registered assets into the first episode result so their
1441
- // descriptions / states flow into the merge. Names are deduplicated globally
1442
- // by mergeEpisodeResults, so registering them first gives the canonical
1443
- // (registry) descriptions priority over anything implied by scene references.
1444
- if (results.length > 0) {
1445
- const first = results[0];
1446
- for (const key of ["actors", "locations", "props", "speakers", "state_definitions"]) {
1447
- first[key] = [...asList(bible[key]), ...asList(first[key])];
1448
955
  }
1449
- }
1450
- const title = (meta.title || strOf(opts["title"]).trim() || path.basename(path.resolve(mdDir)));
1451
- const script = mergeEpisodeResults(results, title);
1452
- if (globalSynopsis)
1453
- script["synopsis"] = globalSynopsis;
1454
- if (meta.worldview) {
1455
- script["worldview"] = meta.worldview;
1456
- script["worldview_raw"] = meta.worldview;
1457
- }
1458
- if (meta.style)
1459
- script["style"] = meta.style;
1460
- // role_type (主角/配角) is md-driven via 元信息.md `主角:`, not a json patch.
1461
- if (meta.protagonists && meta.protagonists.length > 0) {
1462
- const leads = new Set(meta.protagonists);
1463
- for (const actor of asList(script["actors"])) {
1464
- actor["role_type"] = leads.has(strOf(actor["actor_name"])) ? "主角" : "配角";
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
+ }
1465
968
  }
1466
969
  }
1467
- const dd = directDir(workspace);
1468
- fs.mkdirSync(dd, { recursive: true });
1469
- // Write source.txt so the existing direct validate/export downstream (which
1470
- // gates on source.txt existing) works unchanged. For a parse-origin script the
1471
- // authored md *is* the source, so we persist the concatenated bodies.
1472
- fs.mkdirSync(workspace, { recursive: true });
1473
- fs.writeFileSync(path.join(workspace, "source.txt"), sourceChunks.join("\n\n") + "\n", "utf-8");
1474
- const scriptPath = path.join(dd, "script.initial.json");
1475
- writeJson(scriptPath, script);
1476
- const validation = validateScript(workspace, scriptPath, { requireSource: false });
1477
- const passed = Boolean(validation["passed"]);
1478
- updateRunState(workspace, {
1479
- status: passed ? "ready_for_agent" : "needs_agent_repair",
1480
- command: "parse",
1481
- init_stage: "complete",
1482
- provider: "parse",
1483
- source_path: path.resolve(mdDir),
1484
- script_path: scriptPath,
1485
- validation_path: path.join(dd, "validation.json"),
1486
- episode_total: results.length,
1487
- episode_completed: results.length,
1488
- review_status: "pending",
1489
- review_missing: [...REVIEW_TARGETS],
1490
- inspected_targets: [],
1491
- patch_count: 0,
1492
- exportable: true,
1493
- last_error: null,
1494
- });
1495
- const stats = validation["stats"] ?? {};
1496
- const blockingOrError = Boolean(validation["has_blocking"]) ||
1497
- asList(validation["issues"]).some((it) => isDict(it) && (it["severity"] === "blocking" || it["severity"] === "error"));
1498
- // --publish: md → 校验 → 直接入库(不经 direct 的 inspect/review/export 门禁)。
1499
- // md 是唯一真相源:校验不过就报问题、不发布,让 agent 直接改 md 后重跑 parse --publish。
1500
- if (opts["publish"]) {
1501
- if (blockingOrError) {
1502
- return [{
1503
- title: "PARSE PUBLISH BLOCKED: 校验未过,改 md 后重跑",
1504
- result: [
1505
- `asset docs: ${assetDocsFound.join(" / ") || "(none)"}`,
1506
- `validation: needs repair`,
1507
- ],
1508
- artifacts: [scriptPath, path.join(dd, "validation.json")],
1509
- issues: summarizeIssues(asList(validation["issues"])),
1510
- next: ["按 issue 直接改对应的 ep_*.md / 人物·场景·道具·发声源.md / 元信息.md,再重跑 `scriptctl parse <dir> --publish`。"],
1511
- }, EXIT_NEEDS_AGENT];
1512
- }
1513
- const client = scriptOutputClient(opts);
1514
- const baseRevision = await currentRevisionOrZero(client);
1515
- const scriptHash = sha256Text(JSON.stringify(sortDeep(script)));
1516
- const requestId = strOf(opts["request_id"]).trim() || `scriptctl-parse:${scriptHash}`;
1517
- let replaceRes;
1518
- try {
1519
- replaceRes = await client.replaceScript({ requestId, baseRevision, script, source: "ctl" });
970
+ const lines = [];
971
+ let completeEpisodes = 0;
972
+ const errorEpisodes = [];
973
+ for (const ep of episodes) {
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);
1520
984
  }
1521
- catch (exc) {
1522
- if (exc instanceof ScriptOutputApiError)
1523
- throw apiErrorToCli("PARSE PUBLISH BLOCKED: 入库写入失败", exc);
1524
- throw exc;
985
+ else {
986
+ label = `pending (${st.done}/${st.total} done)`;
1525
987
  }
1526
- return [{
1527
- title: "PARSE PUBLISHED: 剧本已入库",
1528
- result: [
1529
- `episodes: ${stats["episodes"] ?? results.length}`,
1530
- `scenes: ${stats["scenes"] ?? 0}`,
1531
- `actions: ${stats["actions"] ?? 0}`,
1532
- `worldview: ${meta.worldview || "(unset)"}`,
1533
- `base_revision: ${baseRevision}`,
1534
- `revision: ${replaceRes["revision"]}`,
1535
- `idempotent: ${replaceRes["idempotent"] ?? false}`,
1536
- ],
1537
- artifacts: [scriptPath],
1538
- next: ["剧本已入库。若发现问题,直接改 md 再 `scriptctl parse <dir> --publish` 覆盖。"],
1539
- }, EXIT_OK];
988
+ lines.push(`${episodeResultKey(ep)} ${label}`);
1540
989
  }
1541
990
  const report = {
1542
- title: passed
1543
- ? "PARSE COMPLETE: 中间稿已生成(加 --publish 直接入库)"
1544
- : "PARSE NEEDS AGENT: 校验有问题,直接改 md 再 parse",
991
+ title: "DIRECT STATUS",
1545
992
  result: [
1546
- `episodes: ${stats["episodes"] ?? results.length}`,
1547
- `scenes: ${stats["scenes"] ?? 0}`,
1548
- `actions: ${stats["actions"] ?? 0}`,
1549
- `asset docs: ${assetDocsFound.join(" / ") || "(none)"}`,
1550
- `worldview: ${meta.worldview || "(unset)"}`,
1551
- `style: ${meta.style || "(unset)"}`,
1552
- `synopsis: ${globalSynopsis ? "yes" : "no"}`,
1553
- `validation: ${passed ? "passed" : "needs repair"}`,
993
+ `episodes: ${completeEpisodes}/${episodes.length} done`,
994
+ `batches: ${doneTotal}/${batches.length} done, ${errorTotal} error`,
995
+ `error episodes: ${errorEpisodes.length === 0 ? "-" : errorEpisodes.join(", ")}`,
996
+ ...lines,
1554
997
  ],
1555
- artifacts: [scriptPath, path.join(dd, "validation.json")],
1556
- issues: summarizeIssues(asList(validation["issues"])),
1557
- next: passed
1558
- ? [" `--publish` 直接入库。"]
1559
- : ["按 issue 直接改对应的 md(ep_*.md / 资产 md / 元信息.md),再重跑 parse。"],
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."],
1560
1002
  };
1561
- return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
1003
+ return [report, EXIT_OK];
1004
+ }
1005
+ export function summarizeIssues(issues) {
1006
+ if (issues.length === 0)
1007
+ return [];
1008
+ const counts = {};
1009
+ for (const item of issues) {
1010
+ const sev = strOf(item["severity"]);
1011
+ counts[sev] = (counts[sev] ?? 0) + 1;
1012
+ }
1013
+ const parts = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)).map(([sev, c]) => `${sev}: ${c}`);
1014
+ const first = issues[0];
1015
+ return [parts.join("; "), `first: ${first["code"]} - ${first["summary"]}`];
1562
1016
  }
1563
1017
  // ---------------------------------------------------------------------------
1564
1018
  // command_validate