@lingjingai/scriptctl 0.10.3 → 0.11.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,13 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
1
3
  import * as path from "node:path";
2
- import { CliError, EXIT_INPUT, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, directDir, exists, readJson, readText, sha256Text, writeJson, } from "../common.js";
4
+ import { randomUUID } from "node:crypto";
5
+ import { CliError, EXIT_INPUT, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, directDir, exists, readJson, readText, scriptJsonPath, sha256Text, writeJson, } from "../common.js";
3
6
  import { applyPatchOperations, collectAssetRefs, collectStateRefs, parseAnyAddress, parseStateTarget, validateScript, PATCH_OP_SCHEMA, } from "../domain/script-core.js";
4
7
  import { RemoteScriptOutputStore } from "../infra/script-output-api.js";
5
8
  import { LocalScriptOutputStore } from "../infra/local-script-output-store.js";
6
9
  import { ScriptOutputApiError, resolveOutputMode, } from "../infra/script-output-store.js";
7
- import { readRunState, summarizeIssues, updateRunState, } from "./direct.js";
10
+ import { markMetadataConfidenceReviewed, readRunState, reviewBlockers, summarizeIssues, updateRunState, markPatched, } from "./direct.js";
8
11
  function strOf(v) {
9
12
  if (v === null || v === undefined)
10
13
  return "";
@@ -62,12 +65,14 @@ function episodeCharCounts(scenes) {
62
65
  export class ScriptEditSession {
63
66
  workspace;
64
67
  script;
68
+ scriptPath;
65
69
  client;
66
70
  projectGroupNo;
67
71
  revision;
68
72
  constructor(opts) {
69
73
  this.workspace = opts.workspace;
70
74
  this.script = opts.script;
75
+ this.scriptPath = opts.scriptPath ?? null;
71
76
  this.client = opts.client ?? null;
72
77
  this.projectGroupNo = opts.projectGroupNo ?? null;
73
78
  this.revision = opts.revision ?? null;
@@ -76,10 +81,16 @@ export class ScriptEditSession {
76
81
  return this.client !== null;
77
82
  }
78
83
  get artifactLabel() {
79
- // Public label — keeps the project id + revision (not secret; the user
80
- // supplies the project id via --project-group-no/env) so two project groups
81
- // at the same revision stay distinguishable in report.artifacts.
82
- return `script-output/${this.projectGroupNo}@revision/${this.revision ?? 0}`;
84
+ if (this.remote) {
85
+ // Public label strips the internal `db:/script-output/project-groups/`
86
+ // storage prefix (which leaks implementation), but keeps the project id
87
+ // and revision so two different project groups at the same revision stay
88
+ // distinguishable in user-visible report.artifacts entries. The project
89
+ // id is not a secret: the user supplies it themselves via
90
+ // `--project-group-no` or env config.
91
+ return `script-output/${this.projectGroupNo}@revision/${this.revision ?? 0}`;
92
+ }
93
+ return this.scriptPath ?? "";
83
94
  }
84
95
  }
85
96
  export function scriptOutputClient(opts) {
@@ -201,7 +212,7 @@ export function apiErrorToCli(title, exc) {
201
212
  isConflict
202
213
  ? "Reload the latest script revision and retry."
203
214
  : isMissing
204
- ? "Run `scriptctl import push` to publish a script first."
215
+ ? "Run `scriptctl direct export` to publish a script first."
205
216
  : "Run `scriptctl doctor` to verify gateway configuration; retry if it was transient.",
206
217
  ],
207
218
  errorCode: isConflict
@@ -220,28 +231,12 @@ export async function currentRevisionOrZero(client) {
220
231
  }
221
232
  catch (exc) {
222
233
  if (exc instanceof ScriptOutputApiError) {
223
- // WORKAROUND (see TODO.md "getRevision not-found signal"): the script-output
224
- // backend returns a generic error (UNKNOWN_EXCEPTION, not a recognizable
225
- // "no script yet" signal) when a project group has no published script, so
226
- // a first publish can't tell first-publish from a real failure here.
227
- // getRevision is only an optimistic-lock hint, not a correctness gate, so
228
- // fall back to baseRevision 0 and let replaceScript decide. This is SAFE:
229
- // replaceScript enforces the baseRevision lock server-side — it CREATES only
230
- // when no script exists and REJECTS with a revision conflict if one already
231
- // does — so base 0 never overwrites an existing script. A genuine
232
- // gateway/auth failure still surfaces on the subsequent replaceScript call.
233
- // Note: once a script exists, getRevision returns cleanly, so this fallback
234
- // only ever engages on the no-script (first-publish) path.
235
- return 0;
234
+ throw apiErrorToCli("SCRIPT API BLOCKED: Revision query failed", exc);
236
235
  }
237
236
  throw exc;
238
237
  }
239
238
  }
240
- // All reads/edits operate on the DB-backed final script. The intermediate
241
- // script.initial.json is a private artifact between `import init` and
242
- // `import push`; it is not an editable surface.
243
- async function loadScript(opts) {
244
- const workspace = strOf(opts["workspace_path"] || "workspace");
239
+ async function loadRemoteScript(opts, workspace) {
245
240
  const client = scriptOutputClient(opts);
246
241
  let script;
247
242
  let revision;
@@ -260,7 +255,7 @@ async function loadScript(opts) {
260
255
  exitCode: EXIT_INPUT,
261
256
  required: ["existing script-output project document"],
262
257
  received: [`projectGroupNo=${client.projectGroupNo}`],
263
- nextSteps: ["Run `scriptctl import push` to publish a script first."],
258
+ nextSteps: ["Run `scriptctl direct export` first, or pass `--script-path` to edit a local intermediate script JSON."],
264
259
  errorCode: "SCRIPT_NOT_FOUND",
265
260
  });
266
261
  }
@@ -281,22 +276,66 @@ async function loadScript(opts) {
281
276
  revision: Number((revision ?? {})["revision"] ?? 0),
282
277
  });
283
278
  }
279
+ async function loadScriptForEdit(opts) {
280
+ const workspace = strOf(opts["workspace_path"] || "workspace");
281
+ if (!opts["script_path"]) {
282
+ return loadRemoteScript(opts, workspace);
283
+ }
284
+ const p = scriptJsonPath(opts);
285
+ if (!exists(p)) {
286
+ throw new CliError("SCRIPT BLOCKED: Local script file not found", "Local script file not found.", {
287
+ exitCode: EXIT_INPUT,
288
+ required: ["--script-path existing local script JSON"],
289
+ received: [p],
290
+ nextSteps: ["Pass --script-path to an existing local intermediate script JSON, or omit --script-path to use DB-backed script-output."],
291
+ errorCode: "SCRIPT_NOT_FOUND",
292
+ });
293
+ }
294
+ let data;
295
+ try {
296
+ data = readJson(p);
297
+ }
298
+ catch (exc) {
299
+ throw new CliError("SCRIPT BLOCKED: script JSON invalid", "script JSON invalid.", {
300
+ exitCode: EXIT_INPUT,
301
+ required: ["valid JSON"],
302
+ received: [`${p}: ${exc.message}`],
303
+ nextSteps: ["Fix the local script JSON syntax before editing."],
304
+ errorCode: "SCRIPT_JSON_INVALID",
305
+ });
306
+ }
307
+ if (!isDict(data)) {
308
+ throw new CliError("SCRIPT BLOCKED: script root invalid", "script root invalid.", {
309
+ exitCode: EXIT_USAGE,
310
+ required: ["script root object"],
311
+ received: [Array.isArray(data) ? "array" : typeof data],
312
+ nextSteps: ["Use a valid script document object."],
313
+ errorCode: "SCRIPT_ROOT_INVALID",
314
+ });
315
+ }
316
+ return new ScriptEditSession({ workspace, scriptPath: p, script: data });
317
+ }
284
318
  function validateSession(session, opts = {}) {
285
- const persist = opts.persist ?? true;
286
- // Validate the in-memory DB script directly (no tmp file). scriptData also
287
- // tells validateScript not to read the local asset_metadata.json/episode_plan.json
288
- // parse artifacts — those describe the local initial.json, not the DB script.
289
- // requireSource:false (DB validation never needs source.txt). persist:false so
290
- // validateScript doesn't write; we own the write below to set the nicer label.
291
- const validation = validateScript(session.workspace, null, {
292
- requireSource: false,
293
- scriptData: session.script,
294
- persist: false,
295
- });
296
- validation["script_path"] = session.artifactLabel;
297
- if (persist)
319
+ const requireSource = opts.requireSource ?? false;
320
+ if (!session.remote) {
321
+ return validateScript(session.workspace, session.scriptPath, { requireSource });
322
+ }
323
+ const tmpPath = path.join(os.tmpdir(), `scriptctl-db-script-${randomUUID()}.json`);
324
+ try {
325
+ fs.writeFileSync(tmpPath, JSON.stringify(session.script, null, 2) + "\n", "utf-8");
326
+ const validation = validateScript(session.workspace, tmpPath, { requireSource });
327
+ validation["script_path"] = session.artifactLabel;
298
328
  writeJson(path.join(directDir(session.workspace), "validation.json"), validation);
299
- return validation;
329
+ return validation;
330
+ }
331
+ finally {
332
+ try {
333
+ fs.unlinkSync(tmpPath);
334
+ }
335
+ catch {
336
+ // ignore
337
+ }
338
+ }
300
339
  }
301
340
  function validationIssuePath(issue) {
302
341
  if (issue["path"])
@@ -386,8 +425,12 @@ function requestIdForScriptWrite(opts, op, payload) {
386
425
  return `scriptctl:${op}:${sha256Text(canonical)}`;
387
426
  }
388
427
  async function saveScriptSession(session, opts, op) {
428
+ if (!session.remote) {
429
+ writeJson(session.scriptPath, session.script);
430
+ return [null, false];
431
+ }
389
432
  if (session.client === null)
390
- throw new Error("script session missing client");
433
+ throw new Error("remote script session missing client");
391
434
  const baseRevision = Number(session.revision ?? 0);
392
435
  const requestId = requestIdForScriptWrite(opts, op, { script: session.script });
393
436
  let res;
@@ -445,11 +488,67 @@ function patchOperationsFromPayload(payload) {
445
488
  return operations;
446
489
  }
447
490
  // ---------------------------------------------------------------------------
491
+ // command_patch (legacy: applies to script.initial.json directly with source)
492
+ // ---------------------------------------------------------------------------
493
+ export function commandPatch(opts) {
494
+ const workspace = strOf(opts["workspace_path"] || "workspace");
495
+ const patchPath = strOf(opts["patch"]);
496
+ const scriptPath = path.join(directDir(workspace), "script.initial.json");
497
+ const sourcePath = path.join(workspace, "source.txt");
498
+ if (!exists(patchPath)) {
499
+ throw new CliError("PATCH BLOCKED: Patch file not found", "Patch file not found.", {
500
+ exitCode: EXIT_INPUT,
501
+ required: ["--patch: existing JSON file"],
502
+ received: [patchPath],
503
+ nextSteps: ["Write a patch JSON file and rerun patch."],
504
+ });
505
+ }
506
+ if (!exists(scriptPath) || !exists(sourcePath)) {
507
+ throw new CliError("PATCH BLOCKED: Required artifact missing", "Required artifact missing.", {
508
+ exitCode: EXIT_INPUT,
509
+ required: ["source.txt and script.initial.json"],
510
+ received: [scriptPath, sourcePath],
511
+ nextSteps: ["Run scriptctl direct init first."],
512
+ });
513
+ }
514
+ let payload;
515
+ try {
516
+ payload = readJson(patchPath);
517
+ }
518
+ catch (exc) {
519
+ throw new CliError("PATCH BLOCKED: Patch JSON invalid", "Patch JSON invalid.", {
520
+ exitCode: EXIT_USAGE,
521
+ required: ["valid JSON patch file"],
522
+ received: [`${patchPath}: ${exc.message}`],
523
+ nextSteps: ["Fix patch JSON and rerun patch."],
524
+ });
525
+ }
526
+ const operations = patchOperationsFromPayload(payload);
527
+ const script = readJson(scriptPath);
528
+ const applied = applyPatchOperations(script, readText(sourcePath), operations);
529
+ writeJson(scriptPath, script);
530
+ markMetadataConfidenceReviewed(workspace, operations);
531
+ const validation = validateScript(workspace, scriptPath);
532
+ markPatched(workspace, applied.length);
533
+ const passed = Boolean(validation["passed"]);
534
+ const report = {
535
+ title: passed ? "PATCH APPLIED: Validation passed" : "PATCH APPLIED: Repair issues remain",
536
+ result: [
537
+ `operations: ${applied.length}`,
538
+ `validation: ${passed ? "passed" : "needs repair"}`,
539
+ ],
540
+ artifacts: [scriptPath, path.join(directDir(workspace), "validation.json")],
541
+ issues: summarizeIssues(asList(validation["issues"])),
542
+ next: [passed ? "Export the final script." : "Inspect remaining issues and apply another patch."],
543
+ };
544
+ return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
545
+ }
546
+ // ---------------------------------------------------------------------------
448
547
  // commandScriptValidate / commandScriptInspect
449
548
  // ---------------------------------------------------------------------------
450
549
  export async function commandScriptValidate(opts) {
451
550
  const workspace = strOf(opts["workspace_path"] || "workspace");
452
- const session = await loadScript(opts);
551
+ const session = await loadScriptForEdit(opts);
453
552
  const validation = validateSession(session);
454
553
  await syncValidationResult(session, validation);
455
554
  const stats = validation["stats"] ?? {};
@@ -579,13 +678,13 @@ export async function commandScriptPatch(opts) {
579
678
  }
580
679
  const operations = patchOperationsFromPayload(payload);
581
680
  const dryRun = Boolean(opts["dry_run"]);
582
- const session = await loadScript(opts);
681
+ const session = await loadScriptForEdit(opts);
583
682
  const script = session.script;
584
683
  const applied = applyPatchOperations(script, scriptSourceText(workspace), operations);
585
684
  if (dryRun) {
586
685
  // Validate the in-memory mutated script WITHOUT calling saveScriptSession.
587
686
  // Run validate but skip syncValidationResult (which writes to remote) too.
588
- const validation = validateSession(session, { persist: false });
687
+ const validation = validateSession(session);
589
688
  const passed = Boolean(validation["passed"]);
590
689
  const report = {
591
690
  title: passed ? "SCRIPT PATCH DRY-RUN PASSED" : "SCRIPT PATCH DRY-RUN: Validation needs repair",
@@ -607,6 +706,12 @@ export async function commandScriptPatch(opts) {
607
706
  const [newRevision, idempotent] = await saveScriptSession(session, opts, applied.length === 1 ? applied[0] : "script.patch");
608
707
  const validation = validateSession(session);
609
708
  await syncValidationResult(session, validation, newRevision);
709
+ // Direct-stage local patches: keep run_state in sync so reviewBlockers can
710
+ // unblock direct export. Detect via script path under directDir.
711
+ if (!session.remote && session.scriptPath && session.scriptPath.startsWith(directDir(workspace))) {
712
+ markMetadataConfidenceReviewed(workspace, operations);
713
+ markPatched(workspace, applied.length);
714
+ }
610
715
  const passed = Boolean(validation["passed"]);
611
716
  const resultLines = [
612
717
  `operations: ${applied.length}`,
@@ -634,7 +739,7 @@ export async function commandScriptPatch(opts) {
634
739
  // ---------------------------------------------------------------------------
635
740
  async function applySingleScriptOp(opts, op) {
636
741
  const workspace = strOf(opts["workspace_path"] || "workspace");
637
- const session = await loadScript(opts);
742
+ const session = await loadScriptForEdit(opts);
638
743
  const script = session.script;
639
744
  const applied = applyPatchOperations(script, scriptSourceText(workspace), [op]);
640
745
  const opName = applied[0] ?? strOf(op["op"]);
@@ -676,7 +781,7 @@ function summarizeScriptOp(_script, op) {
676
781
  return `已执行 ${kind}`;
677
782
  }
678
783
  async function commandStateRefs(opts, plan = false) {
679
- const session = await loadScript(opts);
784
+ const session = await loadScriptForEdit(opts);
680
785
  const script = session.script;
681
786
  const args = asList(opts["_args"]);
682
787
  const [targetKind, targetId, stateId] = parseStateTarget(args[0] ?? "");
@@ -715,48 +820,72 @@ async function commandStateRefs(opts, plan = false) {
715
820
  return [report, EXIT_OK];
716
821
  }
717
822
  // ---------------------------------------------------------------------------
718
- // command_push (import push)
823
+ // command_export (direct export)
719
824
  // ---------------------------------------------------------------------------
720
- export async function commandPush(opts) {
825
+ export async function commandExport(opts) {
721
826
  const workspace = strOf(opts["workspace_path"] || "workspace");
827
+ const force = Boolean(opts["force"]);
722
828
  const scriptPath = path.join(directDir(workspace), "script.initial.json");
723
829
  if (!exists(scriptPath)) {
724
- throw new CliError("PUSH BLOCKED: script.initial.json not found", "script.initial.json not found.", {
830
+ throw new CliError("EXPORT BLOCKED: script.initial.json not found", "script.initial.json not found.", {
725
831
  exitCode: EXIT_INPUT,
726
832
  required: ["workspace/draft/scriptctl/direct/script.initial.json"],
727
833
  received: [scriptPath],
728
- nextSteps: ["Run scriptctl import init first."],
834
+ nextSteps: ["Run scriptctl direct init first."],
729
835
  });
730
836
  }
731
837
  const state = readRunState(workspace);
732
838
  const provider = strOf(state["provider"]);
733
- // SCRIPTCTL_ALLOW_MOCK_PUSH is the current name; SCRIPTCTL_ALLOW_MOCK_EXPORT is
734
- // still honored for back-compat with environments configured before the rename.
735
- const allowMock = process.env.SCRIPTCTL_ALLOW_MOCK_PUSH === "1" || process.env.SCRIPTCTL_ALLOW_MOCK_EXPORT === "1";
736
- if (provider === "mock" && !allowMock) {
839
+ if (provider === "mock" && process.env.SCRIPTCTL_ALLOW_MOCK_EXPORT !== "1") {
737
840
  const report = {
738
- title: "PUSH BLOCKED: Mock provider result",
739
- result: ["script.initial.json was produced by --provider mock and was not pushed."],
841
+ title: "EXPORT BLOCKED: Mock provider result",
842
+ result: ["script.initial.json was produced by --provider mock and was not exported."],
740
843
  artifacts: [path.join(directDir(workspace), "run_state.json")],
741
844
  next: ["Rerun init with --provider anthropic for deliverable conversion."],
742
845
  };
743
846
  return [report, EXIT_NEEDS_AGENT];
744
847
  }
848
+ const missingReview = reviewBlockers(state);
849
+ if (missingReview.length > 0) {
850
+ const report = {
851
+ title: "EXPORT BLOCKED: Agent review incomplete",
852
+ result: ["script.initial.json was not exported.", `missing review: ${missingReview.join(", ")}`],
853
+ artifacts: [path.join(directDir(workspace), "run_state.json")],
854
+ next: ["Run inspect for each missing target, or apply a structured patch, then validate/export."],
855
+ };
856
+ return [report, EXIT_NEEDS_AGENT];
857
+ }
858
+ const validation = validateScript(workspace, scriptPath);
859
+ const blockingOrError = Boolean(validation["has_blocking"]) ||
860
+ asList(validation["issues"]).some((it) => isDict(it) && (it["severity"] === "blocking" || it["severity"] === "error"));
861
+ if (!validation["passed"] && (!force || blockingOrError)) {
862
+ const title = force
863
+ ? "EXPORT BLOCKED: Validation errors require repair"
864
+ : "EXPORT BLOCKED: Validation needs agent repair";
865
+ const report = {
866
+ title,
867
+ result: ["script.initial.json was not exported."],
868
+ artifacts: [path.join(directDir(workspace), "validation.json")],
869
+ issues: summarizeIssues(asList(validation["issues"])),
870
+ next: ["Inspect issues and apply structured patches, then validate/export."],
871
+ };
872
+ return [report, EXIT_NEEDS_AGENT];
873
+ }
745
874
  let script;
746
875
  try {
747
876
  script = readJson(scriptPath);
748
877
  }
749
878
  catch (exc) {
750
- throw new CliError("PUSH BLOCKED: script.initial.json invalid", "script.initial.json invalid.", {
879
+ throw new CliError("EXPORT BLOCKED: script.initial.json invalid", "script.initial.json invalid.", {
751
880
  exitCode: EXIT_INPUT,
752
881
  required: ["valid script.initial.json"],
753
882
  received: [`${scriptPath}: ${exc.message}`],
754
- nextSteps: ["Fix script.initial.json or rerun import init."],
883
+ nextSteps: ["Fix script.initial.json or rerun direct init."],
755
884
  errorCode: "SCRIPT_JSON_INVALID",
756
885
  });
757
886
  }
758
887
  if (!isDict(script)) {
759
- throw new CliError("PUSH BLOCKED: script root invalid", "script root invalid.", {
888
+ throw new CliError("EXPORT BLOCKED: script root invalid", "script root invalid.", {
760
889
  exitCode: EXIT_USAGE,
761
890
  required: ["script root object"],
762
891
  received: [Array.isArray(script) ? "array" : typeof script],
@@ -764,14 +893,12 @@ export async function commandPush(opts) {
764
893
  errorCode: "SCRIPT_ROOT_INVALID",
765
894
  });
766
895
  }
767
- // Validation is advisory, not a gate: push always publishes so the agent can
768
- // self-review and repair on the DB script afterwards. requireSource:false so a
769
- // missing source.txt never aborts the publish. Results are still computed and
770
- // synced so the review subagent has the issue list to act on.
771
- const validation = validateScript(workspace, scriptPath, { requireSource: false });
772
896
  const client = scriptOutputClient(opts);
773
897
  const baseRevision = await currentRevisionOrZero(client);
774
- const requestId = requestIdForScriptWrite(opts, "import.push", { script });
898
+ // Sorted-keys serialization mirrors Python json.dumps(sort_keys=True, separators=(",",":"))
899
+ const sortedScript = sortDeep(script);
900
+ const scriptHash = sha256Text(JSON.stringify(sortedScript));
901
+ const requestId = strOf(opts["request_id"]).trim() || `scriptctl-direct-export:${scriptHash}`;
775
902
  let replaceRes;
776
903
  try {
777
904
  replaceRes = await client.replaceScript({
@@ -783,7 +910,7 @@ export async function commandPush(opts) {
783
910
  }
784
911
  catch (exc) {
785
912
  if (exc instanceof ScriptOutputApiError) {
786
- throw apiErrorToCli("SCRIPT API BLOCKED: Push write failed", exc);
913
+ throw apiErrorToCli("SCRIPT API BLOCKED: Export write failed", exc);
787
914
  }
788
915
  throw exc;
789
916
  }
@@ -797,21 +924,17 @@ export async function commandPush(opts) {
797
924
  });
798
925
  await syncValidationResult(remoteSession, validation, revision);
799
926
  const outputLabel = remoteSession.artifactLabel;
800
- updateRunState(workspace, { status: "pushed", output_path: outputLabel });
801
- const passed = Boolean(validation["passed"]);
927
+ updateRunState(workspace, { status: "exported", output_path: outputLabel });
802
928
  const report = {
803
- title: "PUSH COMPLETE: Final script stored in DB",
929
+ title: "EXPORT COMPLETE: Final script stored in DB",
804
930
  result: [
805
- `validation: ${passed ? "passed" : "advisory (review on DB)"}`,
931
+ `validation: ${validation["passed"] ? "passed" : "forced"}`,
806
932
  `base_revision: ${baseRevision}`,
807
933
  `revision: ${revision}`,
808
934
  `idempotent: ${String(Boolean(replaceRes["idempotent"])).toLowerCase()}`,
809
935
  ],
810
- issues: passed ? [] : summarizeIssues(asList(validation["issues"])),
811
936
  artifacts: [outputLabel, path.join(directDir(workspace), "validation.json")],
812
- next: passed
813
- ? ["Self-review the DB script, then proceed to downstream asset or footage stages."]
814
- : ["Self-review and repair the DB script (validate / issues --severity error), then proceed downstream."],
937
+ next: ["Proceed to downstream asset or footage stages."],
815
938
  };
816
939
  return [report, EXIT_OK];
817
940
  }
@@ -957,8 +1080,12 @@ function buildReport(op, title, lines, artifactLabel, next) {
957
1080
  };
958
1081
  }
959
1082
  // ----- summary --------------------------------------------------------------
1083
+ function synopsisPreview(text, limit) {
1084
+ const oneLine = text.replace(/\s+/g, " ").trim();
1085
+ return oneLine.length > limit ? `${oneLine.slice(0, limit)}…` : oneLine;
1086
+ }
960
1087
  export async function commandSummary(opts) {
961
- const session = await loadScript(opts);
1088
+ const session = await loadScriptForEdit(opts);
962
1089
  const script = session.script;
963
1090
  const episodes = asList(script["episodes"]);
964
1091
  const scenes = [];
@@ -977,11 +1104,14 @@ export async function commandSummary(opts) {
977
1104
  `props: ${asList(script["props"]).length}`,
978
1105
  `speakers: ${asList(script["speakers"]).length}`,
979
1106
  ];
1107
+ const synopsis = strOf(script["synopsis"]).trim();
1108
+ if (synopsis)
1109
+ lines.push(`synopsis: ${synopsisPreview(synopsis, 120)}`);
980
1110
  return [buildReport("query.summary", "SCRIPT SUMMARY", lines, session.artifactLabel, ["Use plural-noun queries (episodes / scenes / actions / actors / ...) or edit verbs to act on the script."]), EXIT_OK];
981
1111
  }
982
1112
  // ----- episodes -------------------------------------------------------------
983
1113
  export async function commandEpisodes(opts) {
984
- const session = await loadScript(opts);
1114
+ const session = await loadScriptForEdit(opts);
985
1115
  const script = session.script;
986
1116
  const itemId = strOf(opts["id"]).trim();
987
1117
  const minChars = parseBound(opts["min_chars"]);
@@ -999,7 +1129,9 @@ export async function commandEpisodes(opts) {
999
1129
  continue;
1000
1130
  if (maxChars !== null && chars.total > maxChars)
1001
1131
  continue;
1002
- lines.push(`${ep["episode_id"]}: scenes=${scenes.length}, actions=${actionCount}, chars=${chars.total} (dialogue=${chars.dialogue}, action=${chars.action}), title=${ep["title"] || "-"}`);
1132
+ const epSynopsis = strOf(ep["synopsis"]).trim();
1133
+ const synopsisField = epSynopsis ? `, synopsis=${synopsisPreview(epSynopsis, 60)}` : "";
1134
+ lines.push(`${ep["episode_id"]}: scenes=${scenes.length}, actions=${actionCount}, chars=${chars.total} (dialogue=${chars.dialogue}, action=${chars.action}), title=${ep["title"] || "-"}${synopsisField}`);
1003
1135
  }
1004
1136
  return [buildReport("query.episodes", "EPISODES", lines, session.artifactLabel, ["Use `scriptctl scenes --in <ep>` to drill in, or edit verbs to mutate."]), EXIT_OK];
1005
1137
  }
@@ -1035,7 +1167,7 @@ function formatScene(epId, scene, names) {
1035
1167
  return `${epId}/${scene["scene_id"]} [${space} ${time}] location=${locations.join(",") || "-"} actors=${actors.join(",") || "-"} props=${props.join(",") || "-"} actions=${actionCount}`;
1036
1168
  }
1037
1169
  export async function commandScenes(opts) {
1038
- const session = await loadScript(opts);
1170
+ const session = await loadScriptForEdit(opts);
1039
1171
  const script = session.script;
1040
1172
  const inFilter = parseInFilter(strOf(opts["in"]));
1041
1173
  const hasActor = strOf(opts["has_actor"]).trim();
@@ -1073,7 +1205,7 @@ export async function commandScenes(opts) {
1073
1205
  }
1074
1206
  // ----- actions --------------------------------------------------------------
1075
1207
  export async function commandActions(opts) {
1076
- const session = await loadScript(opts);
1208
+ const session = await loadScriptForEdit(opts);
1077
1209
  const script = session.script;
1078
1210
  const inFilter = parseInFilter(strOf(opts["in"]));
1079
1211
  const grep = strOf(opts["grep"]);
@@ -1258,7 +1390,7 @@ function listAssetsByKind(script, kind, opts) {
1258
1390
  return lines;
1259
1391
  }
1260
1392
  export async function commandActors(opts) {
1261
- const session = await loadScript(opts);
1393
+ const session = await loadScriptForEdit(opts);
1262
1394
  const lines = listAssetsByKind(session.script, "actor", {
1263
1395
  id: strOf(opts["id"]).trim(),
1264
1396
  name: strOf(opts["name"]).trim(),
@@ -1267,7 +1399,7 @@ export async function commandActors(opts) {
1267
1399
  return [buildReport("query.actors", "ACTORS", lines, session.artifactLabel, ["Use `scriptctl rename actor:<id>` / `describe` / `merge` to edit."]), EXIT_OK];
1268
1400
  }
1269
1401
  export async function commandLocations(opts) {
1270
- const session = await loadScript(opts);
1402
+ const session = await loadScriptForEdit(opts);
1271
1403
  const lines = listAssetsByKind(session.script, "location", {
1272
1404
  id: strOf(opts["id"]).trim(),
1273
1405
  name: strOf(opts["name"]).trim(),
@@ -1276,7 +1408,7 @@ export async function commandLocations(opts) {
1276
1408
  return [buildReport("query.locations", "LOCATIONS", lines, session.artifactLabel, ["Use `scriptctl rename location:<id>` etc. to edit."]), EXIT_OK];
1277
1409
  }
1278
1410
  export async function commandProps(opts) {
1279
- const session = await loadScript(opts);
1411
+ const session = await loadScriptForEdit(opts);
1280
1412
  const lines = listAssetsByKind(session.script, "prop", {
1281
1413
  id: strOf(opts["id"]).trim(),
1282
1414
  name: strOf(opts["name"]).trim(),
@@ -1285,7 +1417,7 @@ export async function commandProps(opts) {
1285
1417
  return [buildReport("query.props", "PROPS", lines, session.artifactLabel, ["Use `scriptctl rename prop:<id>` etc. to edit."]), EXIT_OK];
1286
1418
  }
1287
1419
  export async function commandAssets(opts) {
1288
- const session = await loadScript(opts);
1420
+ const session = await loadScriptForEdit(opts);
1289
1421
  const kindFilter = strOf(opts["kind"]).trim();
1290
1422
  const nameOpt = strOf(opts["name"]).trim();
1291
1423
  const idOpt = strOf(opts["id"]).trim();
@@ -1300,7 +1432,7 @@ export async function commandAssets(opts) {
1300
1432
  }
1301
1433
  // ----- speakers -------------------------------------------------------------
1302
1434
  export async function commandSpeakers(opts) {
1303
- const session = await loadScript(opts);
1435
+ const session = await loadScriptForEdit(opts);
1304
1436
  const script = session.script;
1305
1437
  const idOpt = strOf(opts["id"]).trim();
1306
1438
  const nameOpt = strOf(opts["name"]).trim();
@@ -1323,7 +1455,7 @@ export async function commandSpeakers(opts) {
1323
1455
  }
1324
1456
  // ----- issues ---------------------------------------------------------------
1325
1457
  export async function commandIssues(opts) {
1326
- const session = await loadScript(opts);
1458
+ const session = await loadScriptForEdit(opts);
1327
1459
  const severityFilter = strOf(opts["severity"]).trim();
1328
1460
  const codeFilter = strOf(opts["code"]).trim();
1329
1461
  const validation = validateSession(session);
@@ -1345,7 +1477,7 @@ export async function commandIssues(opts) {
1345
1477
  }
1346
1478
  // ----- refs (unified reverse lookup) ----------------------------------------
1347
1479
  export async function commandRefs(opts) {
1348
- const session = await loadScript(opts);
1480
+ const session = await loadScriptForEdit(opts);
1349
1481
  const script = session.script;
1350
1482
  const args = asList(opts["_args"]);
1351
1483
  const target = strOf(args[0]).trim();