@lingjingai/scriptctl 0.4.0 → 0.6.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.
@@ -3,7 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { CliError, EXIT_INPUT, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, directDir, exists, readJson, readText, scriptJsonPath, sha256Text, writeJson, } from "../common.js";
6
- import { applyPatchOperations, collectStateRefs, parseStateTarget, validateScript, } from "../domain/script-core.js";
6
+ import { applyPatchOperations, collectAssetRefs, collectStateRefs, parseAnyAddress, parseStateTarget, validateScript, PATCH_OP_SCHEMA, } from "../domain/script-core.js";
7
7
  import { ScriptOutputApiError, ScriptOutputClient } from "../infra/script-output-api.js";
8
8
  import { markMetadataConfidenceReviewed, readRunState, reviewBlockers, summarizeIssues, updateRunState, markPatched, } from "./direct.js";
9
9
  function strOf(v) {
@@ -80,7 +80,13 @@ export class ScriptEditSession {
80
80
  }
81
81
  get artifactLabel() {
82
82
  if (this.remote) {
83
- return `db:/script-output/project-groups/${this.projectGroupNo}@revision/${this.revision ?? 0}`;
83
+ // Public label — strips the internal `db:/script-output/project-groups/`
84
+ // storage prefix (which leaks implementation), but keeps the project id
85
+ // and revision so two different project groups at the same revision stay
86
+ // distinguishable in user-visible report.artifacts entries. The project
87
+ // id is not a secret: the user supplies it themselves via
88
+ // `--project-group-no` or env config.
89
+ return `script-output/${this.projectGroupNo}@revision/${this.revision ?? 0}`;
84
90
  }
85
91
  return this.scriptPath ?? "";
86
92
  }
@@ -90,25 +96,85 @@ export function scriptOutputClient(opts) {
90
96
  return ScriptOutputClient.fromEnv(strOf(opts["project_group_no"]).trim() || null);
91
97
  }
92
98
  catch (exc) {
93
- const msg = exc instanceof Error ? exc.message : String(exc);
94
- throw new CliError("SCRIPT API BLOCKED: Gateway configuration missing", msg, {
99
+ // ScriptOutputApiError carries a `configMissing` discriminator so callers
100
+ // can show the user which slot is unconfigured without naming the env var.
101
+ if (exc instanceof ScriptOutputApiError) {
102
+ const { title, message } = describeConfigMissing(exc.configMissing);
103
+ throw new CliError(title, message, {
104
+ exitCode: EXIT_INPUT,
105
+ required: [
106
+ "AWB_BASE_URL, LINGJING_AWB_ACCESS_KEY, and SANDBOX_PROJECT_GROUP_NO (or --project-group-no)",
107
+ ],
108
+ received: [exc.message, `configMissing: ${exc.configMissing ?? "unknown"}`],
109
+ nextSteps: ["Run `scriptctl doctor` to identify missing configuration."],
110
+ errorCode: "SCRIPT_API_CONFIG_MISSING",
111
+ });
112
+ }
113
+ // Belt-and-suspenders: anything else that escapes ScriptOutputClient.fromEnv
114
+ // (a TypeError on a malformed gateway URL, future programmer errors) still
115
+ // surfaces as a clean CliError envelope rather than the generic
116
+ // "RUNTIME FAILED: Unexpected error" path in cli.ts.
117
+ const raw = exc instanceof Error ? exc.message : String(exc);
118
+ throw new CliError("SCRIPT API BLOCKED: gateway not configured", "Gateway is not configured for this environment.", {
95
119
  exitCode: EXIT_INPUT,
96
- required: ["AWB_BASE_URL, LINGJING_AWB_ACCESS_KEY, and SANDBOX_PROJECT_GROUP_NO (or --project-group-no)"],
97
- received: [msg],
98
- nextSteps: ["Run inside a project sandbox or pass the missing configuration explicitly."],
120
+ required: ["valid gateway configuration"],
121
+ received: [raw],
122
+ nextSteps: ["Run `scriptctl doctor` to identify missing configuration."],
99
123
  errorCode: "SCRIPT_API_CONFIG_MISSING",
100
124
  });
101
125
  }
102
126
  }
127
+ function describeConfigMissing(slot) {
128
+ switch (slot) {
129
+ case "project":
130
+ return {
131
+ title: "SCRIPT API BLOCKED: project identifier missing",
132
+ message: "Project identifier is not configured. Pass `--project-group-no <id>` or configure the project for this environment.",
133
+ };
134
+ case "access":
135
+ return {
136
+ title: "SCRIPT API BLOCKED: access credential missing",
137
+ message: "Access credential is not configured for this environment.",
138
+ };
139
+ case "gateway":
140
+ default:
141
+ return {
142
+ title: "SCRIPT API BLOCKED: gateway not configured",
143
+ message: "Gateway is not configured for this environment.",
144
+ };
145
+ }
146
+ }
103
147
  export function apiErrorToCli(title, exc) {
104
- const message = exc.message || "script-output API failed";
105
- const isConflict = message.includes("版本冲突") || message.toLowerCase().includes("revision");
106
- return new CliError(title, message, {
148
+ const rawMessage = exc.message || "script-output API failed";
149
+ // Use the gateway's own canonical Chinese keywords as the only signal. Free
150
+ // text like "revision" or "not found" can leak in from upstream proxies,
151
+ // DNS errors ("name not found"), or HTTP 404 routing mismatches — matching on
152
+ // those substrings would misroute auth/network failures into the conflict /
153
+ // missing-script UX. The fake-gateway contract is `code:9001` + Chinese
154
+ // keyword for both conflict and missing, so the keyword is precise enough.
155
+ const isConflict = rawMessage.includes("版本冲突");
156
+ const isMissing = rawMessage.includes("剧本不存在");
157
+ const publicMessage = isConflict
158
+ ? "Script has been updated by another revision; reload and retry."
159
+ : isMissing
160
+ ? "Script not found on the gateway."
161
+ : "Gateway request failed.";
162
+ return new CliError(title, publicMessage, {
107
163
  exitCode: exc.status !== null && exc.status >= 500 ? EXIT_RUNTIME : EXIT_INPUT,
108
164
  required: ["successful script-output gateway request"],
109
- received: [message],
110
- nextSteps: [isConflict ? "Reload the latest script revision and retry." : "Check gateway URL, access key, project id, and workbench service status."],
111
- errorCode: isConflict ? "SCRIPT_REVISION_CONFLICT" : "SCRIPT_API_FAILED",
165
+ received: [rawMessage, `status: ${exc.status ?? "<unknown>"}`],
166
+ nextSteps: [
167
+ isConflict
168
+ ? "Reload the latest script revision and retry."
169
+ : isMissing
170
+ ? "Run `scriptctl direct export` to publish a script first."
171
+ : "Run `scriptctl doctor` to verify gateway configuration; retry if it was transient.",
172
+ ],
173
+ errorCode: isConflict
174
+ ? "SCRIPT_REVISION_CONFLICT"
175
+ : isMissing
176
+ ? "SCRIPT_NOT_FOUND"
177
+ : "SCRIPT_API_FAILED",
112
178
  });
113
179
  }
114
180
  export async function currentRevisionOrZero(client) {
@@ -140,11 +206,11 @@ async function loadRemoteScript(opts, workspace) {
140
206
  throw exc;
141
207
  }
142
208
  if (script === null) {
143
- throw new CliError("SCRIPT BLOCKED: Final script not found", "Final script data not found.", {
209
+ throw new CliError("SCRIPT BLOCKED: Final script not found", "No script has been published for this project.", {
144
210
  exitCode: EXIT_INPUT,
145
211
  required: ["existing script-output project document"],
146
212
  received: [`projectGroupNo=${client.projectGroupNo}`],
147
- nextSteps: ["Run scriptctl direct export first, or pass --script-path to edit a local intermediate script JSON."],
213
+ nextSteps: ["Run `scriptctl direct export` first, or pass `--script-path` to edit a local intermediate script JSON."],
148
214
  errorCode: "SCRIPT_NOT_FOUND",
149
215
  });
150
216
  }
@@ -295,11 +361,23 @@ async function syncValidationResult(session, validation, revision) {
295
361
  throw exc;
296
362
  }
297
363
  }
298
- function requestIdForScriptWrite(opts, op) {
364
+ // Idempotency key for gateway writes. Returns the agent-provided --request-id
365
+ // when present, otherwise derives a deterministic key from (op, post-mutation
366
+ // script content). baseRevision is intentionally NOT in the hash: in a
367
+ // partial-success retry the client reloads at a new baseRevision but the
368
+ // post-mutation script is identical, so the hash matches and the gateway can
369
+ // dedupe. The cost of including baseRevision (an old version of this code)
370
+ // was that retries produced different ids and committed redundant no-op
371
+ // revisions to the gateway history.
372
+ function requestIdForScriptWrite(opts, op, payload) {
299
373
  const explicit = strOf(opts["request_id"]).trim();
300
374
  if (explicit)
301
375
  return explicit;
302
- return `scriptctl:${op}:${randomUUID()}`;
376
+ const canonical = JSON.stringify(sortDeep({
377
+ op,
378
+ script: payload.script,
379
+ }));
380
+ return `scriptctl:${op}:${sha256Text(canonical)}`;
303
381
  }
304
382
  async function saveScriptSession(session, opts, op) {
305
383
  if (!session.remote) {
@@ -308,12 +386,13 @@ async function saveScriptSession(session, opts, op) {
308
386
  }
309
387
  if (session.client === null)
310
388
  throw new Error("remote script session missing client");
311
- const requestId = requestIdForScriptWrite(opts, op);
389
+ const baseRevision = Number(session.revision ?? 0);
390
+ const requestId = requestIdForScriptWrite(opts, op, { script: session.script });
312
391
  let res;
313
392
  try {
314
393
  res = await session.client.replaceScript({
315
394
  requestId,
316
- baseRevision: Number(session.revision ?? 0),
395
+ baseRevision,
317
396
  script: session.script,
318
397
  source: "ctl",
319
398
  });
@@ -446,107 +525,94 @@ export async function commandScriptValidate(opts) {
446
525
  };
447
526
  return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
448
527
  }
449
- export async function commandScriptInspect(opts) {
450
- const session = await loadScriptForEdit(opts);
451
- const script = session.script;
452
- const target = strOf(opts["target"] || "summary").trim();
453
- const itemId = strOf(opts["id"]).trim();
454
- const lines = [];
455
- if (target === "summary") {
456
- const episodes = asList(script["episodes"]);
457
- const scenes = [];
458
- for (const ep of episodes)
459
- scenes.push(...asList(ep["scenes"]));
460
- const actions = [];
461
- for (const scene of scenes)
462
- actions.push(...asList(scene["actions"]));
463
- lines.push(`title: ${script["title"] || "-"}`, `episodes: ${episodes.length}`, `scenes: ${scenes.length}`, `actions: ${actions.length}`, `actors: ${asList(script["actors"]).length}`, `locations: ${asList(script["locations"]).length}`, `props: ${asList(script["props"]).length}`, `speakers: ${asList(script["speakers"]).length}`);
464
- }
465
- else if (target === "episode") {
466
- const minChars = parseBound(opts["min_chars"]);
467
- const maxChars = parseBound(opts["max_chars"]);
468
- for (const ep of asList(script["episodes"])) {
469
- if (itemId && itemId !== strOf(ep["episode_id"]))
470
- continue;
471
- const scenes = asList(ep["scenes"]);
472
- let actionCount = 0;
473
- const chars = episodeCharCounts(scenes);
474
- for (const scene of scenes)
475
- actionCount += asList(scene["actions"]).length;
476
- if (minChars !== null && chars.total < minChars)
477
- continue;
478
- if (maxChars !== null && chars.total > maxChars)
479
- continue;
480
- lines.push(`${ep["episode_id"]}: scenes=${scenes.length}, actions=${actionCount}, chars=${chars.total} (dialogue=${chars.dialogue}, action=${chars.action}), title=${ep["title"] || "-"}`);
481
- }
482
- }
483
- else if (target === "asset") {
484
- for (const [key, idKey, nameKey] of [
485
- ["actors", "actor_id", "actor_name"],
486
- ["locations", "location_id", "location_name"],
487
- ["props", "prop_id", "prop_name"],
488
- ]) {
489
- for (const asset of asList(script[key])) {
490
- if (itemId && itemId !== strOf(asset[idKey]) && itemId !== strOf(asset[nameKey]))
491
- continue;
492
- const singular = key.slice(0, -1);
493
- lines.push(`${singular} ${asset[idKey]}: ${asset[nameKey]} states=${asList(asset["states"]).length}`);
494
- }
495
- }
496
- }
497
- else if (target === "speaker") {
498
- for (const speaker of asList(script["speakers"])) {
499
- if (itemId && itemId !== strOf(speaker["speaker_id"]))
500
- continue;
501
- lines.push(`${speaker["speaker_id"]}: ${speaker["display_name"]} [${speaker["source_kind"]}] source=${speaker["source_id"]}`);
502
- }
503
- }
504
- else if (target === "issue") {
505
- const validation = validateSession(session);
506
- for (const issue of asList(validation["issues"])) {
507
- if (itemId && itemId !== strOf(issue["code"]) && itemId !== strOf(issue["severity"]))
508
- continue;
509
- const whereParts = [];
510
- for (const k of ["episode", "scene", "action_index"]) {
511
- if (issue[k] !== null && issue[k] !== undefined)
512
- whereParts.push(strOf(issue[k]));
513
- }
514
- const where = whereParts.join(" ");
515
- lines.push(`${issue["severity"]} ${issue["code"]}: ${issue["summary"]}${where ? ` [${where}]` : ""}`);
516
- }
517
- }
518
- else {
519
- throw new CliError("SCRIPT INSPECT BLOCKED: Target invalid", "Target invalid.", {
520
- exitCode: EXIT_USAGE,
521
- required: ["target: summary, episode, asset, speaker, or issue"],
522
- received: [target],
523
- nextSteps: ["Use a supported target."],
524
- errorCode: "INSPECT_TARGET_INVALID",
525
- });
526
- }
527
- const report = {
528
- title: `SCRIPT INSPECT: ${target}`,
529
- op: "script.inspect",
530
- changed: false,
531
- summary: `${target}: ${lines.length} item(s)`,
532
- result: lines.length > 0 ? lines : ["No matching items."],
533
- artifacts: [session.artifactLabel],
534
- next: ["Use script subcommands or script patch for edits."],
535
- };
536
- return [report, EXIT_OK];
528
+ function actionAddress(epId, sceneId, idx) {
529
+ return `${epId}/${sceneId}#${idx}`;
530
+ }
531
+ function formatInspectAction(epId, sceneId, idx, action) {
532
+ const addr = actionAddress(epId, sceneId, idx);
533
+ const type = strOf(action["type"]).trim() || "action";
534
+ const delivery = strOf(action["delivery"]).trim();
535
+ const tag = delivery ? `${type} ${delivery}` : type;
536
+ // Prefer the speaker_id (concrete) over actor_id (asset), and skip both when
537
+ // the action is narrative (no speaker). Format kept on a single line so agents
538
+ // can grep the output.
539
+ const speakerId = strOf(action["speaker_id"]).trim();
540
+ const actorId = strOf(action["actor_id"]).trim();
541
+ const who = speakerId || actorId;
542
+ let content = strOf(action["content"]);
543
+ if (!content) {
544
+ const lines = asList(action["lines"]);
545
+ if (lines.length > 0)
546
+ content = lines.map((l) => strOf(l["content"])).join(" / ");
547
+ }
548
+ const prefix = who ? `${addr} [${tag} ${who}]` : `${addr} [${tag}]`;
549
+ return content ? `${prefix} ${content}` : prefix;
537
550
  }
538
551
  // ---------------------------------------------------------------------------
539
552
  // command_script_patch
540
553
  // ---------------------------------------------------------------------------
541
554
  export async function commandScriptPatch(opts) {
555
+ // --schema: dump op schemas as JSON; no script load, no file required.
556
+ // `--schema <op>` narrows to a single op; bare `--schema` dumps all.
557
+ const schemaOpt = opts["schema"];
558
+ if (schemaOpt !== undefined && schemaOpt !== false) {
559
+ const specificOp = typeof schemaOpt === "string" && schemaOpt !== "" ? schemaOpt : null;
560
+ if (specificOp) {
561
+ const entry = PATCH_OP_SCHEMA[specificOp];
562
+ if (!entry) {
563
+ throw new CliError("SCRIPT PATCH BLOCKED: Unknown op", "Unknown op.", {
564
+ exitCode: EXIT_USAGE,
565
+ required: ["existing dot-style op name (e.g. asset.rename)"],
566
+ received: [specificOp],
567
+ nextSteps: ["Run `scriptctl patch --schema` (no value) to list all ops."],
568
+ errorCode: "PATCH_OP_UNKNOWN",
569
+ });
570
+ }
571
+ const report = {
572
+ title: `PATCH OP SCHEMA: ${specificOp}`,
573
+ op: "patch.schema",
574
+ changed: false,
575
+ summary: entry.description,
576
+ result: [
577
+ `op: ${specificOp}`,
578
+ `required: ${entry.required.join(", ") || "(none)"}`,
579
+ `optional: ${entry.optional.join(", ") || "(none)"}`,
580
+ ],
581
+ body: JSON.stringify({ op: specificOp, ...entry }, null, 2),
582
+ next: ["Construct a patch file with these fields and run `scriptctl patch <file>`."],
583
+ };
584
+ return [report, EXIT_OK];
585
+ }
586
+ const ops = Object.keys(PATCH_OP_SCHEMA).sort();
587
+ const report = {
588
+ title: "PATCH OP SCHEMA",
589
+ op: "patch.schema",
590
+ changed: false,
591
+ summary: `${ops.length} dot-style ops supported`,
592
+ result: ops.map((name) => {
593
+ const e = PATCH_OP_SCHEMA[name];
594
+ return `${name}: required=[${e.required.join(",")}] optional=[${e.optional.join(",")}]`;
595
+ }),
596
+ body: JSON.stringify(PATCH_OP_SCHEMA, null, 2),
597
+ next: ["Use `scriptctl patch --schema <op>` for a single op, or write a patch file and run `scriptctl patch <file>`."],
598
+ };
599
+ return [report, EXIT_OK];
600
+ }
542
601
  const workspace = strOf(opts["workspace_path"] || "workspace");
543
- const session = await loadScriptForEdit(opts);
544
- const script = session.script;
545
602
  const patchPath = strOf(opts["patch"]);
603
+ if (!patchPath) {
604
+ throw new CliError("SCRIPT PATCH BLOCKED: Patch file missing", "Patch file missing.", {
605
+ exitCode: EXIT_USAGE,
606
+ required: ["<file> positional argument"],
607
+ received: ["<empty>"],
608
+ nextSteps: ["Pass a patch JSON file, or use --schema to inspect op definitions."],
609
+ errorCode: "PATCH_FILE_MISSING",
610
+ });
611
+ }
546
612
  if (!exists(patchPath)) {
547
613
  throw new CliError("SCRIPT PATCH BLOCKED: Patch file not found", "Patch file not found.", {
548
614
  exitCode: EXIT_INPUT,
549
- required: ["--patch existing JSON file"],
615
+ required: ["existing patch JSON file"],
550
616
  received: [patchPath],
551
617
  nextSteps: ["Write patch JSON and rerun."],
552
618
  errorCode: "PATCH_NOT_FOUND",
@@ -566,10 +632,41 @@ export async function commandScriptPatch(opts) {
566
632
  });
567
633
  }
568
634
  const operations = patchOperationsFromPayload(payload);
635
+ const dryRun = Boolean(opts["dry_run"]);
636
+ const session = await loadScriptForEdit(opts);
637
+ const script = session.script;
569
638
  const applied = applyPatchOperations(script, scriptSourceText(workspace), operations);
639
+ if (dryRun) {
640
+ // Validate the in-memory mutated script WITHOUT calling saveScriptSession.
641
+ // Run validate but skip syncValidationResult (which writes to remote) too.
642
+ const validation = validateSession(session);
643
+ const passed = Boolean(validation["passed"]);
644
+ const report = {
645
+ title: passed ? "SCRIPT PATCH DRY-RUN PASSED" : "SCRIPT PATCH DRY-RUN: Validation needs repair",
646
+ op: "script.patch.dry-run",
647
+ changed: false,
648
+ summary: `would apply ${applied.length} operation(s) — no write performed`,
649
+ warnings: passed ? [] : summarizeIssues(asList(validation["issues"])),
650
+ result: [
651
+ `operations: ${applied.length}`,
652
+ `validation: ${passed ? "would pass" : "would need repair"}`,
653
+ "dry-run: no remote/local write performed",
654
+ ],
655
+ issues: summarizeIssues(asList(validation["issues"])),
656
+ artifacts: [session.artifactLabel],
657
+ next: [passed ? "Re-run without --dry-run to commit." : "Resolve validation issues before committing."],
658
+ };
659
+ return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
660
+ }
570
661
  const [newRevision, idempotent] = await saveScriptSession(session, opts, applied.length === 1 ? applied[0] : "script.patch");
571
662
  const validation = validateSession(session);
572
663
  await syncValidationResult(session, validation, newRevision);
664
+ // Direct-stage local patches: keep run_state in sync so reviewBlockers can
665
+ // unblock direct export. Detect via script path under directDir.
666
+ if (!session.remote && session.scriptPath && session.scriptPath.startsWith(directDir(workspace))) {
667
+ markMetadataConfidenceReviewed(workspace, operations);
668
+ markPatched(workspace, applied.length);
669
+ }
573
670
  const passed = Boolean(validation["passed"]);
574
671
  const resultLines = [
575
672
  `operations: ${applied.length}`,
@@ -677,189 +774,6 @@ async function commandStateRefs(opts, plan = false) {
677
774
  };
678
775
  return [report, EXIT_OK];
679
776
  }
680
- export async function commandScriptState(opts, action) {
681
- const args = asList(opts["_args"]);
682
- if (action === "refs" || action === "delete-plan") {
683
- return commandStateRefs(opts, action === "delete-plan");
684
- }
685
- if (args.length === 0) {
686
- throw new CliError("SCRIPT STATE BLOCKED: Target missing", "Target missing.", {
687
- exitCode: EXIT_USAGE,
688
- required: ["state or asset target"],
689
- received: ["<empty>"],
690
- nextSteps: ["Run scriptctl script state --help."],
691
- errorCode: "TARGET_MISSING",
692
- });
693
- }
694
- let op;
695
- if (action === "add") {
696
- op = {
697
- op: "state.add",
698
- target: args[0],
699
- name: opts["name"],
700
- description: opts["description"],
701
- state_id: opts["state_id"],
702
- };
703
- }
704
- else if (action === "rename") {
705
- op = { op: "state.rename", target: args[0], name: opts["name"] };
706
- }
707
- else if (action === "describe") {
708
- op = { op: "state.describe", target: args[0], description: opts["description"] };
709
- }
710
- else if (action === "delete-apply") {
711
- op = {
712
- op: "state.delete",
713
- target: args[0],
714
- strategy: opts["strategy"],
715
- replacement: opts["replacement"],
716
- };
717
- }
718
- else {
719
- throw new CliError("SCRIPT STATE BLOCKED: Command invalid", "Command invalid.", {
720
- exitCode: EXIT_USAGE,
721
- required: ["add, rename, describe, refs, delete-plan, delete-apply"],
722
- received: [action],
723
- nextSteps: ["Run scriptctl script state --help."],
724
- errorCode: "STATE_COMMAND_INVALID",
725
- });
726
- }
727
- return applySingleScriptOp(opts, op);
728
- }
729
- export async function commandScriptContext(opts, action) {
730
- const args = asList(opts["_args"]);
731
- if (args.length < 2) {
732
- throw new CliError("SCRIPT CONTEXT BLOCKED: Arguments missing", "Arguments missing.", {
733
- exitCode: EXIT_USAGE,
734
- required: ["scene ref and target"],
735
- received: args,
736
- nextSteps: ["Run scriptctl script context --help."],
737
- errorCode: "ARGS_MISSING",
738
- });
739
- }
740
- if (action !== "set" && action !== "clear") {
741
- throw new CliError("SCRIPT CONTEXT BLOCKED: Command invalid", "Command invalid.", {
742
- exitCode: EXIT_USAGE,
743
- required: ["set or clear"],
744
- received: [action],
745
- nextSteps: ["Run scriptctl script context --help."],
746
- errorCode: "CONTEXT_COMMAND_INVALID",
747
- });
748
- }
749
- const op = {
750
- op: action === "set" ? "context.set" : "context.clear",
751
- at: args[0],
752
- target: args[1],
753
- state: opts["state"],
754
- };
755
- return applySingleScriptOp(opts, op);
756
- }
757
- export async function commandScriptAction(opts, action) {
758
- const args = asList(opts["_args"]);
759
- if (args.length < 2) {
760
- throw new CliError("SCRIPT ACTION BLOCKED: Arguments missing", "Arguments missing.", {
761
- exitCode: EXIT_USAGE,
762
- required: ["action ref and target"],
763
- received: args,
764
- nextSteps: ["Run scriptctl script action --help."],
765
- errorCode: "ARGS_MISSING",
766
- });
767
- }
768
- let op;
769
- if (action === "state-change") {
770
- op = {
771
- op: "action.state.change",
772
- at: args[0],
773
- target: args[1],
774
- to: opts["to"],
775
- from: opts["from"],
776
- effective: opts["effective"] || "after",
777
- };
778
- }
779
- else if (action === "state-remove") {
780
- op = { op: "action.state.remove", at: args[0], target: args[1] };
781
- }
782
- else if (action === "transition-set") {
783
- op = {
784
- op: "action.transition.set",
785
- at: args[0],
786
- target: args[1],
787
- process: opts["process"],
788
- contrast: opts["contrast"],
789
- };
790
- }
791
- else if (action === "transition-clear") {
792
- op = { op: "action.transition.clear", at: args[0], target: args[1] };
793
- }
794
- else {
795
- throw new CliError("SCRIPT ACTION BLOCKED: Command invalid", "Command invalid.", {
796
- exitCode: EXIT_USAGE,
797
- required: ["state-change, state-remove, transition-set, transition-clear"],
798
- received: [action],
799
- nextSteps: ["Run scriptctl script action --help."],
800
- errorCode: "ACTION_COMMAND_INVALID",
801
- });
802
- }
803
- return applySingleScriptOp(opts, op);
804
- }
805
- export async function commandScriptSpeaker(opts, action) {
806
- if (action !== "add") {
807
- throw new CliError("SCRIPT SPEAKER BLOCKED: Command invalid", "Command invalid.", {
808
- exitCode: EXIT_USAGE,
809
- required: ["add"],
810
- received: [action],
811
- nextSteps: ["Run scriptctl script speaker --help."],
812
- errorCode: "SPEAKER_COMMAND_INVALID",
813
- });
814
- }
815
- const op = {
816
- op: "speaker.add",
817
- kind: opts["kind"],
818
- name: opts["name"],
819
- source_id: opts["source_id"],
820
- voice_desc: opts["voice_desc"],
821
- speaker_id: opts["speaker_id"],
822
- };
823
- return applySingleScriptOp(opts, op);
824
- }
825
- export async function commandScriptDialogue(opts, action) {
826
- const args = asList(opts["_args"]);
827
- if (args.length === 0) {
828
- throw new CliError("SCRIPT DIALOGUE BLOCKED: Action ref missing", "Action ref missing.", {
829
- exitCode: EXIT_USAGE,
830
- required: ["action ref"],
831
- received: ["<empty>"],
832
- nextSteps: ["Run scriptctl script dialogue --help."],
833
- errorCode: "ACTION_REF_MISSING",
834
- });
835
- }
836
- let op;
837
- if (action === "speakers") {
838
- op = {
839
- op: "dialogue.speakers",
840
- at: args[0],
841
- speakers: opts["speaker"] || [],
842
- delivery: opts["delivery"] || "simultaneous",
843
- };
844
- }
845
- else if (action === "overlap") {
846
- op = {
847
- op: "dialogue.overlap",
848
- at: args[0],
849
- lines: opts["line"] || [],
850
- };
851
- }
852
- else {
853
- throw new CliError("SCRIPT DIALOGUE BLOCKED: Command invalid", "Command invalid.", {
854
- exitCode: EXIT_USAGE,
855
- required: ["speakers or overlap"],
856
- received: [action],
857
- nextSteps: ["Run scriptctl script dialogue --help."],
858
- errorCode: "DIALOGUE_COMMAND_INVALID",
859
- });
860
- }
861
- return applySingleScriptOp(opts, op);
862
- }
863
777
  // ---------------------------------------------------------------------------
864
778
  // command_export (direct export)
865
779
  // ---------------------------------------------------------------------------
@@ -990,4 +904,1256 @@ export function sortDeep(value) {
990
904
  }
991
905
  return value;
992
906
  }
907
+ // ===========================================================================
908
+ // 0.6.0 plural-noun query commands (replace `commandScriptInspect --target X`)
909
+ // ===========================================================================
910
+ //
911
+ // Each command is a thin read-only wrapper around the underlying script
912
+ // document. They all return a Report shaped like inspect's existing output but
913
+ // without the `--target` flag — the verb itself encodes the target. Filters
914
+ // use `--in <addr>` (compose with the address parsers) plus per-target
915
+ // predicates (--name / --kind / --has / --grep / --context / --type / --actor
916
+ // / --speaker / --severity / --code / --has-{actor,location,prop}).
917
+ // Resolve the speaker_id → source actor mapping once per call. Used by
918
+ // commandActions's --actor filter when the action speaks via a speaker.
919
+ function speakerSourceActorMap(script) {
920
+ const map = new Map();
921
+ for (const s of asList(script["speakers"])) {
922
+ const sourceKind = strOf(s["source_kind"]);
923
+ if (sourceKind === "actor") {
924
+ const sid = strOf(s["speaker_id"]);
925
+ const src = strOf(s["source_id"]);
926
+ if (sid && src)
927
+ map.set(sid, src);
928
+ }
929
+ }
930
+ return map;
931
+ }
932
+ function speakerIdsInAction(action) {
933
+ const ids = new Set();
934
+ const direct = strOf(action["speaker_id"]).trim();
935
+ if (direct)
936
+ ids.add(direct);
937
+ for (const sp of asList(action["speakers"])) {
938
+ const id = strOf(sp["speaker_id"]).trim();
939
+ if (id)
940
+ ids.add(id);
941
+ }
942
+ for (const line of asList(action["lines"])) {
943
+ const id = strOf(line["speaker_id"]).trim();
944
+ if (id)
945
+ ids.add(id);
946
+ }
947
+ return ids;
948
+ }
949
+ function actorIdsInAction(action, speakerToActor) {
950
+ const ids = new Set();
951
+ const direct = strOf(action["actor_id"]).trim();
952
+ if (direct)
953
+ ids.add(direct);
954
+ for (const spkId of speakerIdsInAction(action)) {
955
+ const actor = speakerToActor.get(spkId);
956
+ if (actor)
957
+ ids.add(actor);
958
+ }
959
+ return ids;
960
+ }
961
+ function parseInFilter(raw) {
962
+ if (!raw)
963
+ return { epId: null, sceneId: null, actionIndex: null };
964
+ try {
965
+ const addr = parseAnyAddress(raw);
966
+ if (addr.kind === "action")
967
+ return { epId: addr.episodeId, sceneId: addr.sceneId, actionIndex: addr.actionIndex };
968
+ if (addr.kind === "scene")
969
+ return { epId: addr.episodeId, sceneId: addr.sceneId, actionIndex: null };
970
+ if (addr.kind === "episode")
971
+ return { epId: addr.episodeId, sceneId: null, actionIndex: null };
972
+ }
973
+ catch {
974
+ // Fall through; raw address not recognized.
975
+ }
976
+ throw new CliError("QUERY BLOCKED: --in address invalid", "--in address invalid.", {
977
+ exitCode: EXIT_USAGE,
978
+ required: ["--in: ep_NNN | ep_NNN/scn_NNN | ep_NNN/scn_NNN#idx"],
979
+ received: [raw],
980
+ nextSteps: ["Pass a recognized address format."],
981
+ errorCode: "IN_ADDRESS_INVALID",
982
+ });
983
+ }
984
+ // Build a grep matcher. /pattern/flags → RegExp, otherwise literal substring.
985
+ // Pre-flight checks block the common ReDoS shapes before they hit V8's regex
986
+ // engine: nested quantifiers like (a+)+, (a*)+, (a*|b*)+ etc. compile fine
987
+ // but hang the process on adversarial input. Since --grep accepts caller-
988
+ // controlled patterns and runs over every action in scope, the safe move is
989
+ // to reject patterns with quantifier-on-group-with-internal-quantifier and
990
+ // cap the pattern length.
991
+ const MAX_GREP_PATTERN_LEN = 256;
992
+ function isDangerousRegex(pattern) {
993
+ if (pattern.length > MAX_GREP_PATTERN_LEN)
994
+ return true;
995
+ // Quantifier ({+,*,?,{n,m}}) outside a group whose interior contains another
996
+ // quantifier — classic catastrophic-backtracking template. The check is a
997
+ // heuristic, not a parser; false positives are tolerated as long as the
998
+ // common ReDoS shapes are blocked.
999
+ return /\([^)]*[*+?{][^)]*\)[*+?{]/.test(pattern);
1000
+ }
1001
+ function buildGrepMatcher(raw) {
1002
+ if (!raw)
1003
+ return null;
1004
+ const m = /^\/(.+)\/([gimsuy]*)$/.exec(raw);
1005
+ if (m) {
1006
+ const pattern = m[1];
1007
+ if (isDangerousRegex(pattern)) {
1008
+ throw new CliError("QUERY BLOCKED: regex unsafe", "regex pattern has nested quantifiers (ReDoS risk) or exceeds the length limit.", {
1009
+ exitCode: EXIT_USAGE,
1010
+ required: [`pattern length <= ${MAX_GREP_PATTERN_LEN}; no quantifier-on-group-with-internal-quantifier (e.g. (a+)+)`],
1011
+ received: [`length=${pattern.length}, sample=${pattern.slice(0, 80)}`],
1012
+ nextSteps: ["Rewrite the regex to avoid nested quantifiers, or use a literal substring (drop the /…/ wrapper)."],
1013
+ errorCode: "REGEX_UNSAFE",
1014
+ });
1015
+ }
1016
+ try {
1017
+ const re = new RegExp(pattern, m[2]);
1018
+ return (hay) => re.test(hay);
1019
+ }
1020
+ catch {
1021
+ // Malformed regex — treat as literal.
1022
+ }
1023
+ }
1024
+ return (hay) => hay.includes(raw);
1025
+ }
1026
+ function buildReport(op, title, lines, artifactLabel, next) {
1027
+ return {
1028
+ title,
1029
+ op,
1030
+ changed: false,
1031
+ summary: `${lines.length} item(s)`,
1032
+ result: lines.length > 0 ? lines : ["No matching items."],
1033
+ artifacts: [artifactLabel],
1034
+ next,
1035
+ };
1036
+ }
1037
+ // ----- summary --------------------------------------------------------------
1038
+ export async function commandSummary(opts) {
1039
+ const session = await loadScriptForEdit(opts);
1040
+ const script = session.script;
1041
+ const episodes = asList(script["episodes"]);
1042
+ const scenes = [];
1043
+ for (const ep of episodes)
1044
+ scenes.push(...asList(ep["scenes"]));
1045
+ const actions = [];
1046
+ for (const scene of scenes)
1047
+ actions.push(...asList(scene["actions"]));
1048
+ const lines = [
1049
+ `title: ${script["title"] || "-"}`,
1050
+ `episodes: ${episodes.length}`,
1051
+ `scenes: ${scenes.length}`,
1052
+ `actions: ${actions.length}`,
1053
+ `actors: ${asList(script["actors"]).length}`,
1054
+ `locations: ${asList(script["locations"]).length}`,
1055
+ `props: ${asList(script["props"]).length}`,
1056
+ `speakers: ${asList(script["speakers"]).length}`,
1057
+ ];
1058
+ 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];
1059
+ }
1060
+ // ----- episodes -------------------------------------------------------------
1061
+ export async function commandEpisodes(opts) {
1062
+ const session = await loadScriptForEdit(opts);
1063
+ const script = session.script;
1064
+ const itemId = strOf(opts["id"]).trim();
1065
+ const minChars = parseBound(opts["min_chars"]);
1066
+ const maxChars = parseBound(opts["max_chars"]);
1067
+ const lines = [];
1068
+ for (const ep of asList(script["episodes"])) {
1069
+ if (itemId && itemId !== strOf(ep["episode_id"]))
1070
+ continue;
1071
+ const scenes = asList(ep["scenes"]);
1072
+ let actionCount = 0;
1073
+ const chars = episodeCharCounts(scenes);
1074
+ for (const scene of scenes)
1075
+ actionCount += asList(scene["actions"]).length;
1076
+ if (minChars !== null && chars.total < minChars)
1077
+ continue;
1078
+ if (maxChars !== null && chars.total > maxChars)
1079
+ continue;
1080
+ lines.push(`${ep["episode_id"]}: scenes=${scenes.length}, actions=${actionCount}, chars=${chars.total} (dialogue=${chars.dialogue}, action=${chars.action}), title=${ep["title"] || "-"}`);
1081
+ }
1082
+ return [buildReport("query.episodes", "EPISODES", lines, session.artifactLabel, ["Use `scriptctl scenes --in <ep>` to drill in, or edit verbs to mutate."]), EXIT_OK];
1083
+ }
1084
+ // ----- scenes ---------------------------------------------------------------
1085
+ function nameMaps(script) {
1086
+ const out = { actor: new Map(), location: new Map(), prop: new Map() };
1087
+ for (const a of asList(script["actors"]))
1088
+ out.actor.set(strOf(a["actor_id"]), strOf(a["actor_name"]));
1089
+ for (const l of asList(script["locations"]))
1090
+ out.location.set(strOf(l["location_id"]), strOf(l["location_name"]));
1091
+ for (const p of asList(script["props"]))
1092
+ out.prop.set(strOf(p["prop_id"]), strOf(p["prop_name"]));
1093
+ return out;
1094
+ }
1095
+ function formatScene(epId, scene, names) {
1096
+ const ctx = isDict(scene["context"]) ? scene["context"] : {};
1097
+ const env = isDict(scene["environment"]) ? scene["environment"] : {};
1098
+ const space = strOf(env["space"]) || "-";
1099
+ const time = strOf(env["time"]) || "-";
1100
+ const locations = asList(ctx["locations"]).map((r) => {
1101
+ const id = strOf(r["location_id"]);
1102
+ return `${id}(${names.location.get(id) || "?"})`;
1103
+ });
1104
+ const actors = asList(ctx["actors"]).map((r) => {
1105
+ const id = strOf(r["actor_id"]);
1106
+ return `${id}(${names.actor.get(id) || "?"})`;
1107
+ });
1108
+ const props = asList(ctx["props"]).map((r) => {
1109
+ const id = strOf(r["prop_id"]);
1110
+ return `${id}(${names.prop.get(id) || "?"})`;
1111
+ });
1112
+ const actionCount = asList(scene["actions"]).length;
1113
+ return `${epId}/${scene["scene_id"]} [${space} ${time}] location=${locations.join(",") || "-"} actors=${actors.join(",") || "-"} props=${props.join(",") || "-"} actions=${actionCount}`;
1114
+ }
1115
+ export async function commandScenes(opts) {
1116
+ const session = await loadScriptForEdit(opts);
1117
+ const script = session.script;
1118
+ const inFilter = parseInFilter(strOf(opts["in"]));
1119
+ const hasActor = strOf(opts["has_actor"]).trim();
1120
+ const hasLocation = strOf(opts["has_location"]).trim();
1121
+ const hasProp = strOf(opts["has_prop"]).trim();
1122
+ const names = nameMaps(script);
1123
+ const lines = [];
1124
+ for (const ep of asList(script["episodes"])) {
1125
+ const epId = strOf(ep["episode_id"]);
1126
+ if (inFilter.epId && inFilter.epId !== epId)
1127
+ continue;
1128
+ for (const scene of asList(ep["scenes"])) {
1129
+ const sceneId = strOf(scene["scene_id"]);
1130
+ if (inFilter.sceneId && inFilter.sceneId !== sceneId)
1131
+ continue;
1132
+ if (hasActor) {
1133
+ const refs = asList((scene["context"] || {})["actors"]);
1134
+ if (!refs.some((r) => strOf(r["actor_id"]) === hasActor))
1135
+ continue;
1136
+ }
1137
+ if (hasLocation) {
1138
+ const refs = asList((scene["context"] || {})["locations"]);
1139
+ if (!refs.some((r) => strOf(r["location_id"]) === hasLocation))
1140
+ continue;
1141
+ }
1142
+ if (hasProp) {
1143
+ const refs = asList((scene["context"] || {})["props"]);
1144
+ if (!refs.some((r) => strOf(r["prop_id"]) === hasProp))
1145
+ continue;
1146
+ }
1147
+ lines.push(formatScene(epId, scene, names));
1148
+ }
1149
+ }
1150
+ return [buildReport("query.scenes", "SCENES", lines, session.artifactLabel, ["Use `scriptctl actions --in <ep/scn>` to see actions, or edit verbs to mutate."]), EXIT_OK];
1151
+ }
1152
+ // ----- actions --------------------------------------------------------------
1153
+ export async function commandActions(opts) {
1154
+ const session = await loadScriptForEdit(opts);
1155
+ const script = session.script;
1156
+ const inFilter = parseInFilter(strOf(opts["in"]));
1157
+ const grep = strOf(opts["grep"]);
1158
+ const typeFilter = strOf(opts["type"]).trim();
1159
+ const actorFilter = strOf(opts["actor"]).trim();
1160
+ const speakerFilter = strOf(opts["speaker"]).trim();
1161
+ const hasFilter = strOf(opts["has"]).trim();
1162
+ // Whitelist --has: silently accepting unknown values means a typo like
1163
+ // "state_changes" (underscore) passed the filter-missing guard but bypassed
1164
+ // every concrete predicate, returning the whole script.
1165
+ const HAS_FILTERS = new Set(["state-changes", "transition", "lines"]);
1166
+ if (hasFilter && !HAS_FILTERS.has(hasFilter)) {
1167
+ throw new CliError("QUERY BLOCKED: --has value invalid", "--has value invalid.", {
1168
+ exitCode: EXIT_USAGE,
1169
+ required: [`--has: one of ${[...HAS_FILTERS].join(", ")}`],
1170
+ received: [hasFilter],
1171
+ nextSteps: ["Use a supported --has value."],
1172
+ errorCode: "HAS_FILTER_INVALID",
1173
+ });
1174
+ }
1175
+ const contextN = (() => {
1176
+ const n = opts["context"];
1177
+ if (n === undefined || n === null || n === "")
1178
+ return 0;
1179
+ const parsed = Number(n);
1180
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0;
1181
+ })();
1182
+ const grepMatcher = buildGrepMatcher(grep);
1183
+ const speakerToActor = (actorFilter || speakerFilter) ? speakerSourceActorMap(script) : new Map();
1184
+ if (!inFilter.epId && !grep && !actorFilter && !speakerFilter && !typeFilter && !hasFilter && inFilter.actionIndex === null) {
1185
+ throw new CliError("QUERY BLOCKED: actions filter missing", "actions filter missing.", {
1186
+ exitCode: EXIT_USAGE,
1187
+ required: ["at least one of --in / --grep / --type / --actor / --speaker / --has"],
1188
+ received: ["<empty>"],
1189
+ nextSteps: ["Add a filter — dumping every action across all episodes is too large."],
1190
+ errorCode: "ACTIONS_FILTER_MISSING",
1191
+ });
1192
+ }
1193
+ const allInScope = [];
1194
+ const matchKeys = new Set();
1195
+ for (const ep of asList(script["episodes"])) {
1196
+ const epId = strOf(ep["episode_id"]);
1197
+ if (inFilter.epId && inFilter.epId !== epId)
1198
+ continue;
1199
+ for (const scene of asList(ep["scenes"])) {
1200
+ const sceneId = strOf(scene["scene_id"]);
1201
+ if (inFilter.sceneId && inFilter.sceneId !== sceneId)
1202
+ continue;
1203
+ const actions = asList(scene["actions"]);
1204
+ for (let i = 0; i < actions.length; i++) {
1205
+ if (inFilter.actionIndex !== null && inFilter.actionIndex !== i)
1206
+ continue;
1207
+ const a = actions[i];
1208
+ if (typeFilter && strOf(a["type"]).trim() !== typeFilter)
1209
+ continue;
1210
+ if (speakerFilter && !speakerIdsInAction(a).has(speakerFilter))
1211
+ continue;
1212
+ if (actorFilter && !actorIdsInAction(a, speakerToActor).has(actorFilter))
1213
+ continue;
1214
+ if (hasFilter === "state-changes" && asList(a["state_changes"]).length === 0)
1215
+ continue;
1216
+ if (hasFilter === "transition" && !isDict(a["transition_prompt"]))
1217
+ continue;
1218
+ if (hasFilter === "lines" && asList(a["lines"]).length === 0)
1219
+ continue;
1220
+ if (grepMatcher) {
1221
+ let hay = strOf(a["content"]);
1222
+ for (const line of asList(a["lines"]))
1223
+ hay += "\n" + strOf(line["content"]);
1224
+ if (!grepMatcher(hay))
1225
+ continue;
1226
+ }
1227
+ const key = `${epId}/${sceneId}#${i}`;
1228
+ matchKeys.add(key);
1229
+ allInScope.push({ epId, sceneId, idx: i, action: a });
1230
+ }
1231
+ }
1232
+ }
1233
+ // --context N: expand matches to include neighbors within each scene.
1234
+ const lines = [];
1235
+ if (contextN > 0 && matchKeys.size > 0) {
1236
+ // Walk again, emitting any hit within N of a match (same scene).
1237
+ const matchBySceneIdx = new Map();
1238
+ for (const k of matchKeys) {
1239
+ const [scene, idxStr] = k.split("#");
1240
+ const set = matchBySceneIdx.get(scene) ?? new Set();
1241
+ set.add(parseInt(idxStr, 10));
1242
+ matchBySceneIdx.set(scene, set);
1243
+ }
1244
+ for (const ep of asList(script["episodes"])) {
1245
+ const epId = strOf(ep["episode_id"]);
1246
+ for (const scene of asList(ep["scenes"])) {
1247
+ const sceneKey = `${epId}/${scene["scene_id"]}`;
1248
+ const matchIdxSet = matchBySceneIdx.get(sceneKey);
1249
+ if (!matchIdxSet)
1250
+ continue;
1251
+ const actions = asList(scene["actions"]);
1252
+ for (let i = 0; i < actions.length; i++) {
1253
+ let withinRange = false;
1254
+ for (const m of matchIdxSet) {
1255
+ if (Math.abs(i - m) <= contextN) {
1256
+ withinRange = true;
1257
+ break;
1258
+ }
1259
+ }
1260
+ if (!withinRange)
1261
+ continue;
1262
+ lines.push(formatInspectAction(epId, strOf(scene["scene_id"]), i, actions[i]));
1263
+ }
1264
+ }
1265
+ }
1266
+ }
1267
+ else {
1268
+ for (const hit of allInScope) {
1269
+ lines.push(formatInspectAction(hit.epId, hit.sceneId, hit.idx, hit.action));
1270
+ }
1271
+ }
1272
+ return [buildReport("query.actions", "ACTIONS", lines, session.artifactLabel, ["Use `scriptctl replace <addr>` or `scriptctl type <addr> <type>` etc. to edit."]), EXIT_OK];
1273
+ }
1274
+ // ----- actors / locations / props / assets ----------------------------------
1275
+ function listAssetsByKind(script, kind, opts) {
1276
+ const lines = [];
1277
+ const [key, idKey, nameKey] = (() => {
1278
+ switch (kind) {
1279
+ case "actor": return ["actors", "actor_id", "actor_name"];
1280
+ case "location": return ["locations", "location_id", "location_name"];
1281
+ case "prop": return ["props", "prop_id", "prop_name"];
1282
+ }
1283
+ })();
1284
+ // Build set of ids referenced inside the --in scope if filter is set.
1285
+ let scopeIds = null;
1286
+ if (opts.inFilter.epId) {
1287
+ scopeIds = new Set();
1288
+ for (const ep of asList(script["episodes"])) {
1289
+ if (strOf(ep["episode_id"]) !== opts.inFilter.epId)
1290
+ continue;
1291
+ for (const scene of asList(ep["scenes"])) {
1292
+ if (opts.inFilter.sceneId && strOf(scene["scene_id"]) !== opts.inFilter.sceneId)
1293
+ continue;
1294
+ const ctx = isDict(scene["context"]) ? scene["context"] : {};
1295
+ const pluralKey = kind === "actor" ? "actors" : kind === "location" ? "locations" : "props";
1296
+ for (const ref of asList(ctx[pluralKey])) {
1297
+ scopeIds.add(strOf(ref[idKey]));
1298
+ }
1299
+ if (opts.inFilter.actionIndex !== null) {
1300
+ // Narrow further to a single action's refs.
1301
+ const actions = asList(scene["actions"]);
1302
+ const a = actions[opts.inFilter.actionIndex];
1303
+ if (!a)
1304
+ continue;
1305
+ scopeIds = new Set();
1306
+ if (kind === "actor") {
1307
+ const aid = strOf(a["actor_id"]).trim();
1308
+ if (aid)
1309
+ scopeIds.add(aid);
1310
+ }
1311
+ }
1312
+ }
1313
+ }
1314
+ }
1315
+ for (const asset of asList(script[key])) {
1316
+ const id = strOf(asset[idKey]);
1317
+ const name = strOf(asset[nameKey]);
1318
+ if (opts.id && opts.id !== id)
1319
+ continue;
1320
+ if (opts.name && !name.includes(opts.name))
1321
+ continue;
1322
+ if (scopeIds && !scopeIds.has(id))
1323
+ continue;
1324
+ const aliases = asList(asset["aliases"]).map((s) => strOf(s));
1325
+ const role = strOf(asset["role_type"]);
1326
+ const descPresent = strOf(asset["description"]).trim() ? "yes" : "missing";
1327
+ const states = asList(asset["states"]).length;
1328
+ const extra = [];
1329
+ if (aliases.length > 0)
1330
+ extra.push(`aliases=(${aliases.join(",")})`);
1331
+ if (role)
1332
+ extra.push(`role=${role}`);
1333
+ extra.push(`states=${states}`, `description=${descPresent}`);
1334
+ lines.push(`${kind} ${id}: ${name || "-"} ${extra.join(" ")}`);
1335
+ }
1336
+ return lines;
1337
+ }
1338
+ export async function commandActors(opts) {
1339
+ const session = await loadScriptForEdit(opts);
1340
+ const lines = listAssetsByKind(session.script, "actor", {
1341
+ id: strOf(opts["id"]).trim(),
1342
+ name: strOf(opts["name"]).trim(),
1343
+ inFilter: parseInFilter(strOf(opts["in"])),
1344
+ });
1345
+ return [buildReport("query.actors", "ACTORS", lines, session.artifactLabel, ["Use `scriptctl rename actor:<id>` / `describe` / `merge` to edit."]), EXIT_OK];
1346
+ }
1347
+ export async function commandLocations(opts) {
1348
+ const session = await loadScriptForEdit(opts);
1349
+ const lines = listAssetsByKind(session.script, "location", {
1350
+ id: strOf(opts["id"]).trim(),
1351
+ name: strOf(opts["name"]).trim(),
1352
+ inFilter: parseInFilter(strOf(opts["in"])),
1353
+ });
1354
+ return [buildReport("query.locations", "LOCATIONS", lines, session.artifactLabel, ["Use `scriptctl rename location:<id>` etc. to edit."]), EXIT_OK];
1355
+ }
1356
+ export async function commandProps(opts) {
1357
+ const session = await loadScriptForEdit(opts);
1358
+ const lines = listAssetsByKind(session.script, "prop", {
1359
+ id: strOf(opts["id"]).trim(),
1360
+ name: strOf(opts["name"]).trim(),
1361
+ inFilter: parseInFilter(strOf(opts["in"])),
1362
+ });
1363
+ return [buildReport("query.props", "PROPS", lines, session.artifactLabel, ["Use `scriptctl rename prop:<id>` etc. to edit."]), EXIT_OK];
1364
+ }
1365
+ export async function commandAssets(opts) {
1366
+ const session = await loadScriptForEdit(opts);
1367
+ const kindFilter = strOf(opts["kind"]).trim();
1368
+ const nameOpt = strOf(opts["name"]).trim();
1369
+ const idOpt = strOf(opts["id"]).trim();
1370
+ const inFilter = parseInFilter(strOf(opts["in"]));
1371
+ const lines = [];
1372
+ for (const kind of ["actor", "location", "prop"]) {
1373
+ if (kindFilter && kindFilter !== kind)
1374
+ continue;
1375
+ lines.push(...listAssetsByKind(session.script, kind, { id: idOpt, name: nameOpt, inFilter }));
1376
+ }
1377
+ return [buildReport("query.assets", "ASSETS", lines, session.artifactLabel, ["Use `scriptctl actors` / `locations` / `props` for kind-scoped views."]), EXIT_OK];
1378
+ }
1379
+ // ----- speakers -------------------------------------------------------------
1380
+ export async function commandSpeakers(opts) {
1381
+ const session = await loadScriptForEdit(opts);
1382
+ const script = session.script;
1383
+ const idOpt = strOf(opts["id"]).trim();
1384
+ const nameOpt = strOf(opts["name"]).trim();
1385
+ const kindOpt = strOf(opts["kind"]).trim();
1386
+ const lines = [];
1387
+ for (const sp of asList(script["speakers"])) {
1388
+ const id = strOf(sp["speaker_id"]);
1389
+ const name = strOf(sp["display_name"]);
1390
+ const sourceKind = strOf(sp["source_kind"]);
1391
+ const sourceId = strOf(sp["source_id"]);
1392
+ if (idOpt && idOpt !== id)
1393
+ continue;
1394
+ if (nameOpt && !name.includes(nameOpt))
1395
+ continue;
1396
+ if (kindOpt && kindOpt !== sourceKind)
1397
+ continue;
1398
+ lines.push(`${id}: ${name} [${sourceKind}] source=${sourceId || "-"}`);
1399
+ }
1400
+ return [buildReport("query.speakers", "SPEAKERS", lines, session.artifactLabel, ["Use `scriptctl add-speaker` to register more, or `scriptctl refs <spk_id>` to see usage."]), EXIT_OK];
1401
+ }
1402
+ // ----- issues ---------------------------------------------------------------
1403
+ export async function commandIssues(opts) {
1404
+ const session = await loadScriptForEdit(opts);
1405
+ const severityFilter = strOf(opts["severity"]).trim();
1406
+ const codeFilter = strOf(opts["code"]).trim();
1407
+ const validation = validateSession(session);
1408
+ const lines = [];
1409
+ for (const issue of asList(validation["issues"])) {
1410
+ if (severityFilter && severityFilter !== strOf(issue["severity"]))
1411
+ continue;
1412
+ if (codeFilter && codeFilter !== strOf(issue["code"]))
1413
+ continue;
1414
+ const whereParts = [];
1415
+ for (const k of ["episode", "scene", "action_index"]) {
1416
+ if (issue[k] !== null && issue[k] !== undefined)
1417
+ whereParts.push(strOf(issue[k]));
1418
+ }
1419
+ const where = whereParts.join(" ");
1420
+ lines.push(`${issue["severity"]} ${issue["code"]}: ${issue["summary"]}${where ? ` [${where}]` : ""}`);
1421
+ }
1422
+ return [buildReport("query.issues", "ISSUES", lines, session.artifactLabel, ["Use the relevant edit verb to repair each issue."]), EXIT_OK];
1423
+ }
1424
+ // ----- refs (unified reverse lookup) ----------------------------------------
1425
+ export async function commandRefs(opts) {
1426
+ const session = await loadScriptForEdit(opts);
1427
+ const script = session.script;
1428
+ const args = asList(opts["_args"]);
1429
+ const target = strOf(args[0]).trim();
1430
+ if (!target) {
1431
+ throw new CliError("QUERY BLOCKED: refs target missing", "refs target missing.", {
1432
+ exitCode: EXIT_USAGE,
1433
+ required: ["address: actor|location|prop:<id>[/<state_id>] or spk_<id>"],
1434
+ received: ["<empty>"],
1435
+ nextSteps: ["Pass a recognized address."],
1436
+ errorCode: "REFS_TARGET_MISSING",
1437
+ });
1438
+ }
1439
+ const level = strOf(opts["level"]).trim(); // scene|action|""
1440
+ const addr = parseAnyAddress(target);
1441
+ let refs = [];
1442
+ if (addr.kind === "state") {
1443
+ refs = collectStateRefs(script, addr.assetKind, addr.assetId, addr.stateId);
1444
+ }
1445
+ else if (addr.kind === "asset") {
1446
+ refs = collectAssetRefs(script, addr.assetKind, addr.assetId);
1447
+ }
1448
+ else if (addr.kind === "speaker") {
1449
+ refs = collectAssetRefs(script, "speaker", addr.speakerId);
1450
+ }
1451
+ else {
1452
+ throw new CliError("QUERY BLOCKED: refs target invalid", "refs target invalid.", {
1453
+ exitCode: EXIT_USAGE,
1454
+ required: ["asset / state / speaker address"],
1455
+ received: [target],
1456
+ nextSteps: ["Use a kind:id, kind:id/state_id, or spk_id form."],
1457
+ errorCode: "REFS_TARGET_INVALID",
1458
+ });
1459
+ }
1460
+ const lines = [];
1461
+ for (const ref of refs) {
1462
+ const role = strOf(ref["role"]);
1463
+ if (level === "scene" && !role.startsWith("scene_") && role !== "speaker_source")
1464
+ continue;
1465
+ if (level === "action" && (role.startsWith("scene_") || role === "speaker_source"))
1466
+ continue;
1467
+ lines.push(`${ref["location"]} [${ref["role"]}]`);
1468
+ }
1469
+ return [buildReport("query.refs", `REFS ${target}`, lines, session.artifactLabel, ["Use the relevant edit verb (merge / delete --strategy / rename) to mutate."]), EXIT_OK];
1470
+ }
1471
+ // ===========================================================================
1472
+ // 0.6.0 edit verbs (flat top-level — replace `script <group> <leaf>` chains)
1473
+ // ===========================================================================
1474
+ //
1475
+ // Each verb is a thin function over applySingleScriptOp / parseAnyAddress.
1476
+ // Multipolar verbs (delete / merge / move / describe / insert) dispatch on
1477
+ // the first positional address to pick the underlying dot-style patch op.
1478
+ // ----- content-level (action) -----------------------------------------------
1479
+ export async function commandReplace(opts) {
1480
+ const args = asList(opts["_args"]);
1481
+ if (args.length < 1) {
1482
+ throw new CliError("VERB BLOCKED: Action ref missing", "Action ref missing.", {
1483
+ exitCode: EXIT_USAGE,
1484
+ required: ["action ref: ep_001/scn_001#3"],
1485
+ received: ["<empty>"],
1486
+ nextSteps: ["Run `scriptctl --help` for the full command surface."],
1487
+ errorCode: "ACTION_REF_MISSING",
1488
+ });
1489
+ }
1490
+ if (opts["from"] === undefined) {
1491
+ throw new CliError("VERB BLOCKED: --from missing", "--from missing.", {
1492
+ exitCode: EXIT_USAGE,
1493
+ required: ["--from <text>"],
1494
+ received: ["<missing>"],
1495
+ nextSteps: ["Pass --from with the substring to replace."],
1496
+ errorCode: "FROM_TEXT_EMPTY",
1497
+ });
1498
+ }
1499
+ return applySingleScriptOp(opts, {
1500
+ op: "action.content.replace",
1501
+ at: args[0],
1502
+ from: opts["from"],
1503
+ to: opts["to"] ?? "",
1504
+ all: Boolean(opts["all"]),
1505
+ });
1506
+ }
1507
+ export async function commandType(opts) {
1508
+ const args = asList(opts["_args"]);
1509
+ if (args.length < 2) {
1510
+ throw new CliError("VERB BLOCKED: Type args missing", "Type args missing.", {
1511
+ exitCode: EXIT_USAGE,
1512
+ required: ["<action ref> <type>"],
1513
+ received: args,
1514
+ nextSteps: ["Example: scriptctl type ep_001/scn_001#3 dialogue"],
1515
+ errorCode: "ARGS_MISSING",
1516
+ });
1517
+ }
1518
+ return applySingleScriptOp(opts, { op: "action.type.set", at: args[0], type: args[1] });
1519
+ }
1520
+ export async function commandActor(opts) {
1521
+ const args = asList(opts["_args"]);
1522
+ if (args.length < 2) {
1523
+ throw new CliError("VERB BLOCKED: Actor args missing", "Actor args missing.", {
1524
+ exitCode: EXIT_USAGE,
1525
+ required: ["<action ref> <actor_id|none>"],
1526
+ received: args,
1527
+ nextSteps: ["Example: scriptctl actor ep_001/scn_001#3 act_002"],
1528
+ errorCode: "ARGS_MISSING",
1529
+ });
1530
+ }
1531
+ // Empty string is almost always a shell-expansion accident ("$ACTOR" with
1532
+ // $ACTOR unset). Old set_action_actor required explicit allow_null; we
1533
+ // restore the safety by demanding the literal token `none` to clear.
1534
+ if (args[1].trim() === "") {
1535
+ throw new CliError("VERB BLOCKED: actor_id empty", "actor_id is empty.", {
1536
+ exitCode: EXIT_USAGE,
1537
+ required: ["<actor_id> or the literal 'none' to clear"],
1538
+ received: [`"${args[1]}"`],
1539
+ nextSteps: ["Pass an existing actor id, or pass 'none' to explicitly clear."],
1540
+ errorCode: "ACTOR_ID_EMPTY",
1541
+ });
1542
+ }
1543
+ return applySingleScriptOp(opts, { op: "action.actor.set", at: args[0], actor_id: args[1] });
1544
+ }
1545
+ // ----- structural (multipolar delete / move / insert) -----------------------
1546
+ export async function commandDelete(opts) {
1547
+ const args = asList(opts["_args"]);
1548
+ if (args.length < 1) {
1549
+ throw new CliError("VERB BLOCKED: Address missing", "Address missing.", {
1550
+ exitCode: EXIT_USAGE,
1551
+ required: ["address"],
1552
+ received: ["<empty>"],
1553
+ nextSteps: ["Pass an address (action / scene / asset / speaker)."],
1554
+ errorCode: "ADDRESS_MISSING",
1555
+ });
1556
+ }
1557
+ const addr = parseAnyAddress(args[0]);
1558
+ // Per-kind flag whitelist — silently ignoring wrong-kind flags hid real
1559
+ // misuse (e.g. `delete actor:X --force` did NOT bypass ref protection, but
1560
+ // the user assumed it did). Surface the mismatch with an explicit error.
1561
+ const flagsUsed = {
1562
+ strategy: opts["strategy"] !== undefined,
1563
+ replacement: opts["replacement"] !== undefined,
1564
+ force: Boolean(opts["force"]),
1565
+ };
1566
+ function rejectFlags(kind, allowed) {
1567
+ const allow = new Set(allowed);
1568
+ const bad = [];
1569
+ for (const f of ["strategy", "replacement", "force"]) {
1570
+ if (flagsUsed[f] && !allow.has(f))
1571
+ bad.push(`--${f}`);
1572
+ }
1573
+ if (bad.length > 0) {
1574
+ throw new CliError("VERB BLOCKED: flag not applicable", `${bad.join(", ")} cannot be used with ${kind} delete.`, {
1575
+ exitCode: EXIT_USAGE,
1576
+ required: [`for ${kind} delete: ${allowed.length === 0 ? "no extra flags" : allowed.map((f) => `--${f}`).join(" / ")}`],
1577
+ received: bad,
1578
+ nextSteps: ["Drop the unsupported flag(s) and retry."],
1579
+ errorCode: "DELETE_FLAG_INVALID",
1580
+ });
1581
+ }
1582
+ }
1583
+ const op = (() => {
1584
+ if (addr.kind === "action") {
1585
+ rejectFlags("action", []);
1586
+ return { op: "action.delete", at: args[0] };
1587
+ }
1588
+ if (addr.kind === "scene") {
1589
+ rejectFlags("scene", ["force"]);
1590
+ return { op: "scene.delete", at: args[0], force: Boolean(opts["force"]) };
1591
+ }
1592
+ if (addr.kind === "asset") {
1593
+ rejectFlags("asset", ["strategy", "replacement"]);
1594
+ return {
1595
+ op: "asset.delete",
1596
+ target: args[0],
1597
+ strategy: opts["strategy"],
1598
+ replacement: opts["replacement"],
1599
+ };
1600
+ }
1601
+ if (addr.kind === "speaker") {
1602
+ rejectFlags("speaker", ["strategy", "replacement"]);
1603
+ return {
1604
+ op: "speaker.delete",
1605
+ target: addr.speakerId,
1606
+ strategy: opts["strategy"],
1607
+ replacement: opts["replacement"],
1608
+ };
1609
+ }
1610
+ throw new CliError("VERB BLOCKED: delete cannot operate on this address", "delete cannot operate on this address.", {
1611
+ exitCode: EXIT_USAGE,
1612
+ required: ["action / scene / asset / speaker address"],
1613
+ received: [args[0]],
1614
+ nextSteps: ["Use a supported address."],
1615
+ errorCode: "ADDRESS_UNSUPPORTED",
1616
+ });
1617
+ })();
1618
+ return applySingleScriptOp(opts, op);
1619
+ }
1620
+ export async function commandMove(opts) {
1621
+ const args = asList(opts["_args"]);
1622
+ if (args.length < 2) {
1623
+ throw new CliError("VERB BLOCKED: Move args missing", "Move args missing.", {
1624
+ exitCode: EXIT_USAGE,
1625
+ required: ["<from-addr> <to-addr>"],
1626
+ received: args,
1627
+ nextSteps: ["Example: scriptctl move ep_001/scn_001#5 ep_001/scn_002#0"],
1628
+ errorCode: "ARGS_MISSING",
1629
+ });
1630
+ }
1631
+ const addr = parseAnyAddress(args[0]);
1632
+ if (addr.kind === "action") {
1633
+ return applySingleScriptOp(opts, { op: "action.move", at: args[0], to: args[1] });
1634
+ }
1635
+ if (addr.kind === "scene") {
1636
+ return applySingleScriptOp(opts, {
1637
+ op: "scene.move", at: args[0], to: args[1], at_index: opts["at_index"] ?? opts["at"],
1638
+ });
1639
+ }
1640
+ throw new CliError("VERB BLOCKED: move cannot operate on this address", "move cannot operate on this address.", {
1641
+ exitCode: EXIT_USAGE,
1642
+ required: ["action / scene address"],
1643
+ received: [args[0]],
1644
+ nextSteps: ["Use an action or scene address."],
1645
+ errorCode: "ADDRESS_UNSUPPORTED",
1646
+ });
1647
+ }
1648
+ export async function commandInsert(opts) {
1649
+ const args = asList(opts["_args"]);
1650
+ if (args.length < 1) {
1651
+ throw new CliError("VERB BLOCKED: Insert address missing", "Insert address missing.", {
1652
+ exitCode: EXIT_USAGE,
1653
+ required: ["<addr>: ep_NNN/scn_NNN (action insert) or ep_NNN (scene insert)"],
1654
+ received: ["<empty>"],
1655
+ nextSteps: ["Example: scriptctl insert ep_001/scn_001 --type action --content \"...\""],
1656
+ errorCode: "ADDRESS_MISSING",
1657
+ });
1658
+ }
1659
+ const addr = parseAnyAddress(args[0]);
1660
+ // Per-kind flag whitelist. Silently dropping mismatched flags hid two
1661
+ // real bugs:
1662
+ // - scene insert was ignoring --scene-id (the user lost custom IDs).
1663
+ // - mixing action + scene insert flags (`--type` with episode addr, or
1664
+ // `--location` with scene addr) ran the wrong dispatch silently.
1665
+ const flagsUsed = {
1666
+ type: opts["type"] !== undefined,
1667
+ content: opts["content"] !== undefined,
1668
+ actor: (opts["actor_id"] ?? opts["actor"]) !== undefined,
1669
+ speaker: (opts["speaker_id"] ?? opts["speaker"]) !== undefined,
1670
+ location: opts["location"] !== undefined,
1671
+ time: opts["time"] !== undefined,
1672
+ space: opts["space"] !== undefined,
1673
+ "scene-id": opts["scene_id"] !== undefined,
1674
+ };
1675
+ function rejectFlags(kind, allowed) {
1676
+ const allow = new Set(allowed);
1677
+ const bad = [];
1678
+ for (const f of Object.keys(flagsUsed)) {
1679
+ if (flagsUsed[f] && !allow.has(f))
1680
+ bad.push(`--${f}`);
1681
+ }
1682
+ if (bad.length > 0) {
1683
+ throw new CliError("VERB BLOCKED: flag not applicable", `${bad.join(", ")} cannot be used with ${kind} insert.`, {
1684
+ exitCode: EXIT_USAGE,
1685
+ required: [`for ${kind} insert: ${allowed.map((f) => `--${f}`).join(" / ")}`],
1686
+ received: bad,
1687
+ nextSteps: ["Drop the unsupported flag(s) and retry."],
1688
+ errorCode: "INSERT_FLAG_INVALID",
1689
+ });
1690
+ }
1691
+ }
1692
+ if (addr.kind === "scene") {
1693
+ rejectFlags("action", ["type", "content", "actor", "speaker"]);
1694
+ return applySingleScriptOp(opts, {
1695
+ op: "action.insert",
1696
+ at: args[0],
1697
+ type: opts["type"],
1698
+ content: opts["content"],
1699
+ at_index: opts["at_index"] ?? opts["at"],
1700
+ before: opts["before"],
1701
+ after: opts["after"],
1702
+ actor_id: opts["actor_id"] ?? opts["actor"],
1703
+ speaker_id: opts["speaker_id"] ?? opts["speaker"],
1704
+ });
1705
+ }
1706
+ if (addr.kind === "episode") {
1707
+ rejectFlags("scene", ["location", "time", "space", "scene-id"]);
1708
+ return applySingleScriptOp(opts, {
1709
+ op: "scene.insert",
1710
+ at: addr.episodeId,
1711
+ location: opts["location"],
1712
+ time: opts["time"],
1713
+ space: opts["space"],
1714
+ // Forward --scene-id (was previously silently dropped — scene.insert op
1715
+ // reads `scene_id` to honor user-supplied ids for round-trip stability).
1716
+ scene_id: opts["scene_id"],
1717
+ at_index: opts["at_index"] ?? opts["at"],
1718
+ before: opts["before"],
1719
+ after: opts["after"],
1720
+ });
1721
+ }
1722
+ throw new CliError("VERB BLOCKED: insert cannot operate on this address", "insert cannot operate on this address.", {
1723
+ exitCode: EXIT_USAGE,
1724
+ required: ["ep_NNN (scene insert) or ep_NNN/scn_NNN (action insert)"],
1725
+ received: [args[0]],
1726
+ nextSteps: ["Use an episode or scene address."],
1727
+ errorCode: "ADDRESS_UNSUPPORTED",
1728
+ });
1729
+ }
1730
+ export async function commandSplit(opts) {
1731
+ const args = asList(opts["_args"]);
1732
+ if (args.length < 1) {
1733
+ throw new CliError("VERB BLOCKED: Scene ref missing", "Scene ref missing.", {
1734
+ exitCode: EXIT_USAGE,
1735
+ required: ["<ep/scn>"],
1736
+ received: ["<empty>"],
1737
+ nextSteps: ["Example: scriptctl split ep_001/scn_005 --at 7"],
1738
+ errorCode: "ARGS_MISSING",
1739
+ });
1740
+ }
1741
+ // Commander hands back --at as a string; the scene.split handler does a
1742
+ // strict typeof === "number" check (no implicit coercion), so we coerce here
1743
+ // before dispatch. Without this, every CLI `scriptctl split` invocation
1744
+ // would fail with SPLIT_INDEX_INVALID.
1745
+ const rawAt = opts["at_index"] ?? opts["at"];
1746
+ const atIndex = rawAt === undefined || rawAt === null || rawAt === "" ? undefined : Number(rawAt);
1747
+ if (atIndex !== undefined && (!Number.isFinite(atIndex) || !Number.isInteger(atIndex))) {
1748
+ throw new CliError("VERB BLOCKED: --at not an integer", "--at must be an integer.", {
1749
+ exitCode: EXIT_USAGE,
1750
+ required: ["--at <integer>"],
1751
+ received: [String(rawAt)],
1752
+ nextSteps: ["Pass an integer action index."],
1753
+ errorCode: "AT_INDEX_INVALID",
1754
+ });
1755
+ }
1756
+ return applySingleScriptOp(opts, {
1757
+ op: "scene.split",
1758
+ at: args[0],
1759
+ at_index: atIndex,
1760
+ new_scene_id: opts["new_scene_id"],
1761
+ });
1762
+ }
1763
+ export async function commandMerge(opts) {
1764
+ const args = asList(opts["_args"]);
1765
+ if (args.length < 1) {
1766
+ throw new CliError("VERB BLOCKED: Source address missing", "Source address missing.", {
1767
+ exitCode: EXIT_USAGE,
1768
+ required: ["<src-addr> --into <dst-addr>"],
1769
+ received: args,
1770
+ nextSteps: ["Example: scriptctl merge actor:act_001 --into actor:act_002"],
1771
+ errorCode: "ARGS_MISSING",
1772
+ });
1773
+ }
1774
+ const into = strOf(opts["into"]);
1775
+ if (!into) {
1776
+ throw new CliError("VERB BLOCKED: --into missing", "--into missing.", {
1777
+ exitCode: EXIT_USAGE,
1778
+ required: ["--into <dst-addr>"],
1779
+ received: ["<missing>"],
1780
+ nextSteps: ["Pass --into <addr>."],
1781
+ errorCode: "INTO_MISSING",
1782
+ });
1783
+ }
1784
+ const fromAddr = parseAnyAddress(args[0]);
1785
+ const intoAddr = parseAnyAddress(into);
1786
+ if (fromAddr.kind === "asset" && intoAddr.kind === "asset") {
1787
+ return applySingleScriptOp(opts, { op: "asset.merge", from: args[0], into });
1788
+ }
1789
+ if (fromAddr.kind === "scene" && intoAddr.kind === "scene") {
1790
+ return applySingleScriptOp(opts, { op: "scene.merge", from: args[0], into });
1791
+ }
1792
+ throw new CliError("VERB BLOCKED: merge address mismatch", "merge: both sides must be the same kind (asset or scene).", {
1793
+ exitCode: EXIT_USAGE,
1794
+ required: ["asset & asset, or scene & scene"],
1795
+ received: [`${args[0]} → ${into}`],
1796
+ nextSteps: ["Use two addresses of the same kind."],
1797
+ errorCode: "MERGE_ADDR_MISMATCH",
1798
+ });
1799
+ }
1800
+ // ----- asset metadata -------------------------------------------------------
1801
+ export async function commandRename(opts) {
1802
+ const args = asList(opts["_args"]);
1803
+ if (args.length < 2) {
1804
+ throw new CliError("VERB BLOCKED: Rename args missing", "Rename args missing.", {
1805
+ exitCode: EXIT_USAGE,
1806
+ required: ["<asset-addr> <new-name>"],
1807
+ received: args,
1808
+ nextSteps: ["Example: scriptctl rename actor:act_001 \"陈墨\""],
1809
+ errorCode: "ARGS_MISSING",
1810
+ });
1811
+ }
1812
+ return applySingleScriptOp(opts, { op: "asset.rename", target: args[0], name: args[1] });
1813
+ }
1814
+ export async function commandDescribe(opts) {
1815
+ const args = asList(opts["_args"]);
1816
+ if (args.length < 2) {
1817
+ throw new CliError("VERB BLOCKED: Describe args missing", "Describe args missing.", {
1818
+ exitCode: EXIT_USAGE,
1819
+ required: ["<addr> <text>"],
1820
+ received: args,
1821
+ nextSteps: ["Example: scriptctl describe actor:act_001 \"男主\" or scriptctl describe actor:act_001/st_calm \"...\""],
1822
+ errorCode: "ARGS_MISSING",
1823
+ });
1824
+ }
1825
+ const addr = parseAnyAddress(args[0]);
1826
+ if (addr.kind === "state") {
1827
+ return applySingleScriptOp(opts, { op: "state.describe", target: args[0], description: args[1] });
1828
+ }
1829
+ if (addr.kind === "asset") {
1830
+ return applySingleScriptOp(opts, { op: "asset.describe", target: args[0], description: args[1] });
1831
+ }
1832
+ throw new CliError("VERB BLOCKED: describe address invalid", "describe address invalid.", {
1833
+ exitCode: EXIT_USAGE,
1834
+ required: ["asset address (e.g. actor:act_001) or state address (e.g. actor:act_001/st_calm)"],
1835
+ received: [args[0]],
1836
+ nextSteps: ["Use a supported address."],
1837
+ errorCode: "ADDRESS_UNSUPPORTED",
1838
+ });
1839
+ }
1840
+ export async function commandAlias(opts) {
1841
+ const args = asList(opts["_args"]);
1842
+ if (args.length < 1) {
1843
+ throw new CliError("VERB BLOCKED: Asset address missing", "Asset address missing.", {
1844
+ exitCode: EXIT_USAGE,
1845
+ required: ["<asset-addr>"],
1846
+ received: ["<empty>"],
1847
+ nextSteps: ["Example: scriptctl alias actor:act_001 --add \"陈总\""],
1848
+ errorCode: "ARGS_MISSING",
1849
+ });
1850
+ }
1851
+ // appendStr collectors default to []; empty arrays are truthy, so we MUST
1852
+ // check `.length === 0` rather than `!add` (which is always false here).
1853
+ // Without this, passing only --add still runs the remove op with alias=[]
1854
+ // and crashes mid-write at op layer after the add already succeeded.
1855
+ const add = asList(opts["add"]);
1856
+ const remove = asList(opts["remove"]);
1857
+ if (add.length === 0 && remove.length === 0) {
1858
+ throw new CliError("VERB BLOCKED: --add or --remove required", "--add or --remove required.", {
1859
+ exitCode: EXIT_USAGE,
1860
+ required: ["--add <alias> and/or --remove <alias>"],
1861
+ received: ["<empty>"],
1862
+ nextSteps: ["Pass --add and/or --remove at least once."],
1863
+ errorCode: "ALIAS_EMPTY",
1864
+ });
1865
+ }
1866
+ // Run only the ops that have content; either one of them solo, or both.
1867
+ let lastReport = null;
1868
+ if (add.length > 0) {
1869
+ lastReport = await applySingleScriptOp(opts, { op: "asset.alias.add", target: args[0], alias: add });
1870
+ }
1871
+ if (remove.length > 0) {
1872
+ lastReport = await applySingleScriptOp(opts, { op: "asset.alias.remove", target: args[0], alias: remove });
1873
+ }
1874
+ return lastReport;
1875
+ }
1876
+ export async function commandRole(opts) {
1877
+ const args = asList(opts["_args"]);
1878
+ if (args.length < 2) {
1879
+ throw new CliError("VERB BLOCKED: Role args missing", "Role args missing.", {
1880
+ exitCode: EXIT_USAGE,
1881
+ required: ["<actor:id> <主角|配角>"],
1882
+ received: args,
1883
+ nextSteps: ["Example: scriptctl role actor:act_001 主角"],
1884
+ errorCode: "ARGS_MISSING",
1885
+ });
1886
+ }
1887
+ return applySingleScriptOp(opts, { op: "asset.role.set", target: args[0], role_type: args[1] });
1888
+ }
1889
+ export async function commandWorldview(opts) {
1890
+ const args = asList(opts["_args"]);
1891
+ if (args.length < 1) {
1892
+ throw new CliError("VERB BLOCKED: Worldview value missing", "Worldview value missing.", {
1893
+ exitCode: EXIT_USAGE,
1894
+ required: ["<value>"],
1895
+ received: ["<empty>"],
1896
+ nextSteps: ["Example: scriptctl worldview 现代"],
1897
+ errorCode: "ARGS_MISSING",
1898
+ });
1899
+ }
1900
+ return applySingleScriptOp(opts, { op: "meta.worldview.set", worldview: args[0] });
1901
+ }
1902
+ // ----- state management -----------------------------------------------------
1903
+ export async function commandStateAdd(opts) {
1904
+ const args = asList(opts["_args"]);
1905
+ if (args.length < 2) {
1906
+ throw new CliError("VERB BLOCKED: state-add args missing", "state-add args missing.", {
1907
+ exitCode: EXIT_USAGE,
1908
+ required: ["<asset-addr> <state-name>"],
1909
+ received: args,
1910
+ nextSteps: ["Example: scriptctl state-add actor:act_001 \"震惊\""],
1911
+ errorCode: "ARGS_MISSING",
1912
+ });
1913
+ }
1914
+ return applySingleScriptOp(opts, {
1915
+ op: "state.add",
1916
+ target: args[0],
1917
+ name: args[1],
1918
+ description: opts["description"],
1919
+ state_id: opts["state_id"],
1920
+ });
1921
+ }
1922
+ export async function commandStateRename(opts) {
1923
+ const args = asList(opts["_args"]);
1924
+ if (args.length < 2) {
1925
+ throw new CliError("VERB BLOCKED: state-rename args missing", "state-rename args missing.", {
1926
+ exitCode: EXIT_USAGE,
1927
+ required: ["<state-addr> <new-name>"],
1928
+ received: args,
1929
+ nextSteps: ["Example: scriptctl state-rename actor:act_001/st_calm \"平静(新)\""],
1930
+ errorCode: "ARGS_MISSING",
1931
+ });
1932
+ }
1933
+ return applySingleScriptOp(opts, { op: "state.rename", target: args[0], name: args[1] });
1934
+ }
1935
+ export async function commandStateDelete(opts) {
1936
+ const args = asList(opts["_args"]);
1937
+ if (args.length < 1) {
1938
+ throw new CliError("VERB BLOCKED: state-delete address missing", "state-delete address missing.", {
1939
+ exitCode: EXIT_USAGE,
1940
+ required: ["<state-addr>"],
1941
+ received: ["<empty>"],
1942
+ nextSteps: ["Example: scriptctl state-delete actor:act_001/st_calm --strategy remove"],
1943
+ errorCode: "ARGS_MISSING",
1944
+ });
1945
+ }
1946
+ return applySingleScriptOp(opts, {
1947
+ op: "state.delete",
1948
+ target: args[0],
1949
+ strategy: opts["strategy"],
1950
+ replacement: opts["replacement"],
1951
+ });
1952
+ }
1953
+ // ----- scene context ref ----------------------------------------------------
1954
+ export async function commandContext(opts) {
1955
+ const args = asList(opts["_args"]);
1956
+ if (args.length < 2) {
1957
+ throw new CliError("VERB BLOCKED: context args missing", "context args missing.", {
1958
+ exitCode: EXIT_USAGE,
1959
+ required: ["<scene-addr> <asset-addr>"],
1960
+ received: args,
1961
+ nextSteps: ["Example: scriptctl context ep_001/scn_001 actor:act_001 --state st_calm"],
1962
+ errorCode: "ARGS_MISSING",
1963
+ });
1964
+ }
1965
+ // Mutex XOR: help-text promises 'exactly one of --state / --clear /
1966
+ // --remove'. Enforce it instead of silently picking by precedence.
1967
+ const stateSet = opts["state"] !== undefined;
1968
+ const clearSet = Boolean(opts["clear"]);
1969
+ const removeSet = Boolean(opts["remove"]);
1970
+ const flagCount = (stateSet ? 1 : 0) + (clearSet ? 1 : 0) + (removeSet ? 1 : 0);
1971
+ if (flagCount === 0) {
1972
+ throw new CliError("VERB BLOCKED: context flag missing", "context: must specify --state X, --clear, or --remove.", {
1973
+ exitCode: EXIT_USAGE,
1974
+ required: ["one of --state <state_id> / --clear / --remove"],
1975
+ received: ["<missing>"],
1976
+ nextSteps: ["Pick exactly one flag."],
1977
+ errorCode: "CONTEXT_FLAG_MISSING",
1978
+ });
1979
+ }
1980
+ if (flagCount > 1) {
1981
+ const passed = [];
1982
+ if (stateSet)
1983
+ passed.push("--state");
1984
+ if (clearSet)
1985
+ passed.push("--clear");
1986
+ if (removeSet)
1987
+ passed.push("--remove");
1988
+ throw new CliError("VERB BLOCKED: context flags mutually exclusive", "context: --state / --clear / --remove are mutually exclusive.", {
1989
+ exitCode: EXIT_USAGE,
1990
+ required: ["exactly one of --state / --clear / --remove"],
1991
+ received: passed,
1992
+ nextSteps: ["Pick one and retry."],
1993
+ errorCode: "CONTEXT_FLAG_CONFLICT",
1994
+ });
1995
+ }
1996
+ if (removeSet) {
1997
+ return applySingleScriptOp(opts, { op: "context.ref.remove", at: args[0], target: args[1] });
1998
+ }
1999
+ if (clearSet) {
2000
+ return applySingleScriptOp(opts, { op: "context.clear", at: args[0], target: args[1] });
2001
+ }
2002
+ const state = opts["state"];
2003
+ return applySingleScriptOp(opts, {
2004
+ op: "context.set",
2005
+ at: args[0],
2006
+ target: args[1],
2007
+ state: state === "none" ? "" : state,
2008
+ });
2009
+ }
2010
+ // ----- state change / transition --------------------------------------------
2011
+ export async function commandStateChange(opts) {
2012
+ const args = asList(opts["_args"]);
2013
+ if (args.length < 2) {
2014
+ throw new CliError("VERB BLOCKED: state-change args missing", "state-change args missing.", {
2015
+ exitCode: EXIT_USAGE,
2016
+ required: ["<action-addr> <asset-addr>"],
2017
+ received: args,
2018
+ nextSteps: ["Example: scriptctl state-change ep_001/scn_001#3 actor:act_001 --to st_shock"],
2019
+ errorCode: "ARGS_MISSING",
2020
+ });
2021
+ }
2022
+ // Mutex XOR: --to (set) and --clear (remove) cannot coexist.
2023
+ const toSet = opts["to"] !== undefined;
2024
+ const clearSet = Boolean(opts["clear"]);
2025
+ if (toSet && clearSet) {
2026
+ throw new CliError("VERB BLOCKED: state-change flags mutually exclusive", "state-change: --to and --clear are mutually exclusive.", {
2027
+ exitCode: EXIT_USAGE,
2028
+ required: ["exactly one of --to <state> / --clear"],
2029
+ received: ["--to", "--clear"],
2030
+ nextSteps: ["Pick one and retry."],
2031
+ errorCode: "STATE_CHANGE_FLAG_CONFLICT",
2032
+ });
2033
+ }
2034
+ if (!toSet && !clearSet) {
2035
+ throw new CliError("VERB BLOCKED: state-change flag missing", "state-change: must specify --to <state> or --clear.", {
2036
+ exitCode: EXIT_USAGE,
2037
+ required: ["one of --to <state> / --clear"],
2038
+ received: ["<missing>"],
2039
+ nextSteps: ["Pick exactly one flag."],
2040
+ errorCode: "STATE_CHANGE_FLAG_MISSING",
2041
+ });
2042
+ }
2043
+ if (clearSet) {
2044
+ return applySingleScriptOp(opts, { op: "action.state.remove", at: args[0], target: args[1] });
2045
+ }
2046
+ return applySingleScriptOp(opts, {
2047
+ op: "action.state.change",
2048
+ at: args[0],
2049
+ target: args[1],
2050
+ to: opts["to"],
2051
+ from: opts["from"],
2052
+ effective: opts["effective"] || "after",
2053
+ });
2054
+ }
2055
+ export async function commandTransition(opts) {
2056
+ const args = asList(opts["_args"]);
2057
+ if (args.length < 2) {
2058
+ throw new CliError("VERB BLOCKED: transition args missing", "transition args missing.", {
2059
+ exitCode: EXIT_USAGE,
2060
+ required: ["<action-addr> <asset-addr>"],
2061
+ received: args,
2062
+ nextSteps: ["Example: scriptctl transition ep_001/scn_001#3 actor:act_001 --process \"...\" --contrast \"...\""],
2063
+ errorCode: "ARGS_MISSING",
2064
+ });
2065
+ }
2066
+ // Mutex XOR: --clear vs --process/--contrast.
2067
+ const setBoth = opts["process"] !== undefined || opts["contrast"] !== undefined;
2068
+ const clearSet = Boolean(opts["clear"]);
2069
+ if (setBoth && clearSet) {
2070
+ throw new CliError("VERB BLOCKED: transition flags mutually exclusive", "transition: --clear is mutually exclusive with --process/--contrast.", {
2071
+ exitCode: EXIT_USAGE,
2072
+ required: ["either --clear, or --process + --contrast"],
2073
+ received: ["--clear", "--process/--contrast"],
2074
+ nextSteps: ["Pick one mode and retry."],
2075
+ errorCode: "TRANSITION_FLAG_CONFLICT",
2076
+ });
2077
+ }
2078
+ if (!setBoth && !clearSet) {
2079
+ throw new CliError("VERB BLOCKED: transition flag missing", "transition: must specify --process/--contrast or --clear.", {
2080
+ exitCode: EXIT_USAGE,
2081
+ required: ["--process + --contrast, or --clear"],
2082
+ received: ["<missing>"],
2083
+ nextSteps: ["Pick exactly one mode."],
2084
+ errorCode: "TRANSITION_FLAG_MISSING",
2085
+ });
2086
+ }
2087
+ if (clearSet) {
2088
+ return applySingleScriptOp(opts, { op: "action.transition.clear", at: args[0], target: args[1] });
2089
+ }
2090
+ return applySingleScriptOp(opts, {
2091
+ op: "action.transition.set",
2092
+ at: args[0],
2093
+ target: args[1],
2094
+ process: opts["process"],
2095
+ contrast: opts["contrast"],
2096
+ });
2097
+ }
2098
+ // ----- dialogue / speaker ---------------------------------------------------
2099
+ function splitSpeakerList(raw) {
2100
+ if (Array.isArray(raw))
2101
+ return raw.map((v) => strOf(v)).filter((v) => v);
2102
+ return strOf(raw).split(",").map((s) => s.trim()).filter((s) => s);
2103
+ }
2104
+ export async function commandDialogue(opts) {
2105
+ const args = asList(opts["_args"]);
2106
+ if (args.length < 1) {
2107
+ throw new CliError("VERB BLOCKED: dialogue action ref missing", "dialogue action ref missing.", {
2108
+ exitCode: EXIT_USAGE,
2109
+ required: ["<action-addr>"],
2110
+ received: ["<empty>"],
2111
+ nextSteps: ["Example: scriptctl dialogue ep_001/scn_001#3 --speakers spk_001"],
2112
+ errorCode: "ARGS_MISSING",
2113
+ });
2114
+ }
2115
+ const speakers = splitSpeakerList(opts["speakers"]);
2116
+ if (speakers.length === 0) {
2117
+ throw new CliError("VERB BLOCKED: --speakers required", "--speakers required.", {
2118
+ exitCode: EXIT_USAGE,
2119
+ required: ["--speakers id1[,id2,...]"],
2120
+ received: ["<empty>"],
2121
+ nextSteps: ["Pass one or more speaker ids."],
2122
+ errorCode: "SPEAKERS_EMPTY",
2123
+ });
2124
+ }
2125
+ return applySingleScriptOp(opts, {
2126
+ op: "dialogue.speakers",
2127
+ at: args[0],
2128
+ speakers,
2129
+ delivery: opts["delivery"] || (speakers.length > 1 ? "simultaneous" : "single"),
2130
+ });
2131
+ }
2132
+ export async function commandOverlap(opts) {
2133
+ const args = asList(opts["_args"]);
2134
+ if (args.length < 1) {
2135
+ throw new CliError("VERB BLOCKED: overlap action ref missing", "overlap action ref missing.", {
2136
+ exitCode: EXIT_USAGE,
2137
+ required: ["<action-addr>"],
2138
+ received: ["<empty>"],
2139
+ nextSteps: ["Example: scriptctl overlap ep_001/scn_001#3 --line \"spk_a:hi\" --line \"spk_b:hi\""],
2140
+ errorCode: "ARGS_MISSING",
2141
+ });
2142
+ }
2143
+ return applySingleScriptOp(opts, {
2144
+ op: "dialogue.overlap",
2145
+ at: args[0],
2146
+ lines: opts["line"] || [],
2147
+ });
2148
+ }
2149
+ export async function commandAddSpeaker(opts) {
2150
+ return applySingleScriptOp(opts, {
2151
+ op: "speaker.add",
2152
+ kind: opts["kind"],
2153
+ name: opts["name"],
2154
+ source_id: opts["source_id"],
2155
+ voice_desc: opts["voice_desc"],
2156
+ speaker_id: opts["speaker_id"],
2157
+ });
2158
+ }
993
2159
  //# sourceMappingURL=script.js.map