@lingjingai/scriptctl 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/dist/cli.js +99 -11
  2. package/dist/cli.js.map +1 -1
  3. package/dist/common.d.ts +1 -0
  4. package/dist/common.js.map +1 -1
  5. package/dist/domain/script/address.d.ts +30 -0
  6. package/dist/domain/script/address.js +141 -0
  7. package/dist/domain/script/address.js.map +1 -0
  8. package/dist/domain/script/lookups.d.ts +33 -0
  9. package/dist/domain/script/lookups.js +309 -0
  10. package/dist/domain/script/lookups.js.map +1 -0
  11. package/dist/domain/script/patch/apply.d.ts +4 -0
  12. package/dist/domain/script/patch/apply.js +38 -0
  13. package/dist/domain/script/patch/apply.js.map +1 -0
  14. package/dist/domain/script/patch/helpers.d.ts +11 -0
  15. package/dist/domain/script/patch/helpers.js +141 -0
  16. package/dist/domain/script/patch/helpers.js.map +1 -0
  17. package/dist/domain/script/patch/ops-action.d.ts +12 -0
  18. package/dist/domain/script/patch/ops-action.js +371 -0
  19. package/dist/domain/script/patch/ops-action.js.map +1 -0
  20. package/dist/domain/script/patch/ops-asset.d.ts +13 -0
  21. package/dist/domain/script/patch/ops-asset.js +363 -0
  22. package/dist/domain/script/patch/ops-asset.js.map +1 -0
  23. package/dist/domain/script/patch/ops-dialogue.d.ts +5 -0
  24. package/dist/domain/script/patch/ops-dialogue.js +233 -0
  25. package/dist/domain/script/patch/ops-dialogue.js.map +1 -0
  26. package/dist/domain/script/patch/ops-meta.d.ts +3 -0
  27. package/dist/domain/script/patch/ops-meta.js +47 -0
  28. package/dist/domain/script/patch/ops-meta.js.map +1 -0
  29. package/dist/domain/script/patch/ops-scene.d.ts +6 -0
  30. package/dist/domain/script/patch/ops-scene.js +345 -0
  31. package/dist/domain/script/patch/ops-scene.js.map +1 -0
  32. package/dist/domain/script/patch/ops-state.d.ts +8 -0
  33. package/dist/domain/script/patch/ops-state.js +146 -0
  34. package/dist/domain/script/patch/ops-state.js.map +1 -0
  35. package/dist/domain/script/patch/registry.d.ts +2 -0
  36. package/dist/domain/script/patch/registry.js +50 -0
  37. package/dist/domain/script/patch/registry.js.map +1 -0
  38. package/dist/domain/script/patch/types.d.ts +10 -0
  39. package/dist/domain/script/patch/types.js +2 -0
  40. package/dist/domain/script/patch/types.js.map +1 -0
  41. package/dist/domain/script/refs.d.ts +4 -0
  42. package/dist/domain/script/refs.js +152 -0
  43. package/dist/domain/script/refs.js.map +1 -0
  44. package/dist/domain/script/schema.d.ts +6 -0
  45. package/dist/domain/script/schema.js +53 -0
  46. package/dist/domain/script/schema.js.map +1 -0
  47. package/dist/domain/script/selector.d.ts +45 -0
  48. package/dist/domain/script/selector.js +187 -0
  49. package/dist/domain/script/selector.js.map +1 -0
  50. package/dist/domain/script/shared.d.ts +15 -0
  51. package/dist/domain/script/shared.js +37 -0
  52. package/dist/domain/script/shared.js.map +1 -0
  53. package/dist/domain/script/validate.d.ts +22 -0
  54. package/dist/domain/script/validate.js +844 -0
  55. package/dist/domain/script/validate.js.map +1 -0
  56. package/dist/help-text.js +21 -4
  57. package/dist/help-text.js.map +1 -1
  58. package/dist/output.d.ts +1 -1
  59. package/dist/output.js +11 -1
  60. package/dist/output.js.map +1 -1
  61. package/dist/usecases/direct.js +1 -1
  62. package/dist/usecases/direct.js.map +1 -1
  63. package/dist/usecases/parse.js +1 -1
  64. package/dist/usecases/parse.js.map +1 -1
  65. package/dist/usecases/script/actions.d.ts +3 -0
  66. package/dist/usecases/script/actions.js +71 -0
  67. package/dist/usecases/script/actions.js.map +1 -0
  68. package/dist/usecases/script/actor.d.ts +3 -0
  69. package/dist/usecases/script/actor.js +27 -0
  70. package/dist/usecases/script/actor.js.map +1 -0
  71. package/dist/usecases/script/actors.d.ts +3 -0
  72. package/dist/usecases/script/actors.js +15 -0
  73. package/dist/usecases/script/actors.js.map +1 -0
  74. package/dist/usecases/script/add-actor.d.ts +3 -0
  75. package/dist/usecases/script/add-actor.js +13 -0
  76. package/dist/usecases/script/add-actor.js.map +1 -0
  77. package/dist/usecases/script/add-episode.d.ts +3 -0
  78. package/dist/usecases/script/add-episode.js +9 -0
  79. package/dist/usecases/script/add-episode.js.map +1 -0
  80. package/dist/usecases/script/add-location.d.ts +3 -0
  81. package/dist/usecases/script/add-location.js +11 -0
  82. package/dist/usecases/script/add-location.js.map +1 -0
  83. package/dist/usecases/script/add-prop.d.ts +3 -0
  84. package/dist/usecases/script/add-prop.js +11 -0
  85. package/dist/usecases/script/add-prop.js.map +1 -0
  86. package/dist/usecases/script/add-speaker.d.ts +3 -0
  87. package/dist/usecases/script/add-speaker.js +22 -0
  88. package/dist/usecases/script/add-speaker.js.map +1 -0
  89. package/dist/usecases/script/alias.d.ts +3 -0
  90. package/dist/usecases/script/alias.js +40 -0
  91. package/dist/usecases/script/alias.js.map +1 -0
  92. package/dist/usecases/script/assets.d.ts +3 -0
  93. package/dist/usecases/script/assets.js +21 -0
  94. package/dist/usecases/script/assets.js.map +1 -0
  95. package/dist/usecases/script/context.d.ts +3 -0
  96. package/dist/usecases/script/context.js +20 -0
  97. package/dist/usecases/script/context.js.map +1 -0
  98. package/dist/usecases/script/create.d.ts +3 -0
  99. package/dist/usecases/script/create.js +81 -0
  100. package/dist/usecases/script/create.js.map +1 -0
  101. package/dist/usecases/script/delete.d.ts +3 -0
  102. package/dist/usecases/script/delete.js +79 -0
  103. package/dist/usecases/script/delete.js.map +1 -0
  104. package/dist/usecases/script/describe.d.ts +3 -0
  105. package/dist/usecases/script/describe.js +31 -0
  106. package/dist/usecases/script/describe.js.map +1 -0
  107. package/dist/usecases/script/dialogue.d.ts +3 -0
  108. package/dist/usecases/script/dialogue.js +28 -0
  109. package/dist/usecases/script/dialogue.js.map +1 -0
  110. package/dist/usecases/script/do.d.ts +3 -0
  111. package/dist/usecases/script/do.js +130 -0
  112. package/dist/usecases/script/do.js.map +1 -0
  113. package/dist/usecases/script/episodes.d.ts +3 -0
  114. package/dist/usecases/script/episodes.js +36 -0
  115. package/dist/usecases/script/episodes.js.map +1 -0
  116. package/dist/usecases/script/export.d.ts +3 -0
  117. package/dist/usecases/script/export.js +117 -0
  118. package/dist/usecases/script/export.js.map +1 -0
  119. package/dist/usecases/script/insert.d.ts +3 -0
  120. package/dist/usecases/script/insert.js +87 -0
  121. package/dist/usecases/script/insert.js.map +1 -0
  122. package/dist/usecases/script/issues.d.ts +3 -0
  123. package/dist/usecases/script/issues.js +27 -0
  124. package/dist/usecases/script/issues.js.map +1 -0
  125. package/dist/usecases/script/lib.d.ts +77 -0
  126. package/dist/usecases/script/lib.js +849 -0
  127. package/dist/usecases/script/lib.js.map +1 -0
  128. package/dist/usecases/script/locations.d.ts +3 -0
  129. package/dist/usecases/script/locations.js +15 -0
  130. package/dist/usecases/script/locations.js.map +1 -0
  131. package/dist/usecases/script/merge.d.ts +3 -0
  132. package/dist/usecases/script/merge.js +43 -0
  133. package/dist/usecases/script/merge.js.map +1 -0
  134. package/dist/usecases/script/move.d.ts +3 -0
  135. package/dist/usecases/script/move.js +33 -0
  136. package/dist/usecases/script/move.js.map +1 -0
  137. package/dist/usecases/script/overlap.d.ts +3 -0
  138. package/dist/usecases/script/overlap.js +21 -0
  139. package/dist/usecases/script/overlap.js.map +1 -0
  140. package/dist/usecases/script/props.d.ts +3 -0
  141. package/dist/usecases/script/props.js +15 -0
  142. package/dist/usecases/script/props.js.map +1 -0
  143. package/dist/usecases/script/refs.d.ts +3 -0
  144. package/dist/usecases/script/refs.js +61 -0
  145. package/dist/usecases/script/refs.js.map +1 -0
  146. package/dist/usecases/script/rename.d.ts +3 -0
  147. package/dist/usecases/script/rename.js +17 -0
  148. package/dist/usecases/script/rename.js.map +1 -0
  149. package/dist/usecases/script/replace.d.ts +3 -0
  150. package/dist/usecases/script/replace.js +35 -0
  151. package/dist/usecases/script/replace.js.map +1 -0
  152. package/dist/usecases/script/role.d.ts +3 -0
  153. package/dist/usecases/script/role.js +17 -0
  154. package/dist/usecases/script/role.js.map +1 -0
  155. package/dist/usecases/script/scenes.d.ts +3 -0
  156. package/dist/usecases/script/scenes.js +45 -0
  157. package/dist/usecases/script/scenes.js.map +1 -0
  158. package/dist/usecases/script/script-patch.d.ts +3 -0
  159. package/dist/usecases/script/script-patch.js +187 -0
  160. package/dist/usecases/script/script-patch.js.map +1 -0
  161. package/dist/usecases/script/script-validate.d.ts +3 -0
  162. package/dist/usecases/script/script-validate.js +30 -0
  163. package/dist/usecases/script/script-validate.js.map +1 -0
  164. package/dist/usecases/script/session.d.ts +44 -0
  165. package/dist/usecases/script/session.js +527 -0
  166. package/dist/usecases/script/session.js.map +1 -0
  167. package/dist/usecases/script/speakers.d.ts +3 -0
  168. package/dist/usecases/script/speakers.js +28 -0
  169. package/dist/usecases/script/speakers.js.map +1 -0
  170. package/dist/usecases/script/split.d.ts +3 -0
  171. package/dist/usecases/script/split.js +37 -0
  172. package/dist/usecases/script/split.js.map +1 -0
  173. package/dist/usecases/script/state-add.d.ts +3 -0
  174. package/dist/usecases/script/state-add.js +23 -0
  175. package/dist/usecases/script/state-add.js.map +1 -0
  176. package/dist/usecases/script/state-change.d.ts +3 -0
  177. package/dist/usecases/script/state-change.js +24 -0
  178. package/dist/usecases/script/state-change.js.map +1 -0
  179. package/dist/usecases/script/state-delete.d.ts +3 -0
  180. package/dist/usecases/script/state-delete.js +23 -0
  181. package/dist/usecases/script/state-delete.js.map +1 -0
  182. package/dist/usecases/script/state-rename.d.ts +3 -0
  183. package/dist/usecases/script/state-rename.js +17 -0
  184. package/dist/usecases/script/state-rename.js.map +1 -0
  185. package/dist/usecases/script/summary.d.ts +3 -0
  186. package/dist/usecases/script/summary.js +45 -0
  187. package/dist/usecases/script/summary.js.map +1 -0
  188. package/dist/usecases/script/transition.d.ts +3 -0
  189. package/dist/usecases/script/transition.js +24 -0
  190. package/dist/usecases/script/transition.js.map +1 -0
  191. package/dist/usecases/script/type.d.ts +3 -0
  192. package/dist/usecases/script/type.js +24 -0
  193. package/dist/usecases/script/type.js.map +1 -0
  194. package/dist/usecases/script/worldview.d.ts +3 -0
  195. package/dist/usecases/script/worldview.js +18 -0
  196. package/dist/usecases/script/worldview.js.map +1 -0
  197. package/package.json +1 -1
  198. package/dist/domain/script-core.d.ts +0 -78
  199. package/dist/domain/script-core.js +0 -3026
  200. package/dist/domain/script-core.js.map +0 -1
  201. package/dist/usecases/script.d.ts +0 -67
  202. package/dist/usecases/script.js +0 -2348
  203. package/dist/usecases/script.js.map +0 -1
@@ -1,2348 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
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";
6
- import { applyPatchOperations, blankScript, collectAssetRefs, collectStateRefs, parseAnyAddress, parseStateTarget, validateScript, PATCH_OP_SCHEMA, } from "../domain/script-core.js";
7
- import { RemoteScriptOutputStore } from "../infra/script-output-api.js";
8
- import { LocalScriptOutputStore } from "../infra/local-script-output-store.js";
9
- import { ScriptOutputApiError, resolveOutputMode, } from "../infra/script-output-store.js";
10
- import { readRunState, summarizeIssues, updateRunState, } from "./direct.js";
11
- import { sortDeep } from "../domain/direct-core.js";
12
- function strOf(v) {
13
- if (v === null || v === undefined)
14
- return "";
15
- return String(v);
16
- }
17
- function isDict(v) {
18
- return typeof v === "object" && v !== null && !Array.isArray(v);
19
- }
20
- function isList(v) {
21
- return Array.isArray(v);
22
- }
23
- function asList(v) {
24
- return Array.isArray(v) ? v : [];
25
- }
26
- function parseBound(v) {
27
- if (v === null || v === undefined || v === "")
28
- return null;
29
- const n = Number(v);
30
- if (!Number.isFinite(n)) {
31
- throw new CliError("SCRIPT INSPECT BLOCKED: Bound invalid", "--min-chars / --max-chars must be a non-negative integer.", {
32
- exitCode: EXIT_USAGE,
33
- required: ["non-negative integer"],
34
- received: [strOf(v)],
35
- nextSteps: ["Pass --min-chars 1000 (positive integer)."],
36
- errorCode: "INSPECT_BOUND_INVALID",
37
- });
38
- }
39
- return Math.max(0, Math.floor(n));
40
- }
41
- // Count characters in an episode's actions. Splits dialogue-class (`dialogue`,
42
- // `inner_thought`) from narrative/screen-text (`action`) so agents can answer
43
- // "which episodes exceed N spoken chars" vs. raw size. Overlap dialogue stores
44
- // content on `lines[].content`; non-overlap stores it on `content`.
45
- function episodeCharCounts(scenes) {
46
- let dialogue = 0;
47
- let action = 0;
48
- for (const scene of scenes) {
49
- for (const a of asList(scene["actions"])) {
50
- const kind = strOf(a["type"]).trim();
51
- let chars = strOf(a["content"]).length;
52
- for (const line of asList(a["lines"])) {
53
- chars += strOf(line["content"]).length;
54
- }
55
- if (kind === "dialogue" || kind === "inner_thought")
56
- dialogue += chars;
57
- else
58
- action += chars;
59
- }
60
- }
61
- return { total: dialogue + action, dialogue, action };
62
- }
63
- // ---------------------------------------------------------------------------
64
- // ScriptEditSession
65
- // ---------------------------------------------------------------------------
66
- export class ScriptEditSession {
67
- workspace;
68
- script;
69
- scriptPath;
70
- client;
71
- projectGroupNo;
72
- revision;
73
- constructor(opts) {
74
- this.workspace = opts.workspace;
75
- this.script = opts.script;
76
- this.scriptPath = opts.scriptPath ?? null;
77
- this.client = opts.client ?? null;
78
- this.projectGroupNo = opts.projectGroupNo ?? null;
79
- this.revision = opts.revision ?? null;
80
- }
81
- get remote() {
82
- return this.client !== null;
83
- }
84
- get artifactLabel() {
85
- if (this.remote) {
86
- // Public label — strips the internal `db:/script-output/project-groups/`
87
- // storage prefix (which leaks implementation), but keeps the project id
88
- // and revision so two different project groups at the same revision stay
89
- // distinguishable in user-visible report.artifacts entries. The project
90
- // id is not a secret: the user supplies it themselves via
91
- // `--project-group-no` or env config.
92
- return `script-output/${this.projectGroupNo}@revision/${this.revision ?? 0}`;
93
- }
94
- return this.scriptPath ?? "";
95
- }
96
- }
97
- export function scriptOutputClient(opts) {
98
- const mode = resolveOutputMode().mode;
99
- try {
100
- if (mode === "local") {
101
- return LocalScriptOutputStore.fromEnv(strOf(opts["workspace_path"]).trim() || undefined);
102
- }
103
- return RemoteScriptOutputStore.fromEnv(strOf(opts["project_group_no"]).trim() || null);
104
- }
105
- catch (exc) {
106
- // ScriptOutputApiError carries a `configMissing` discriminator so callers
107
- // can show the user which slot is unconfigured without naming the env var.
108
- if (exc instanceof ScriptOutputApiError) {
109
- const { title, message } = describeConfigMissing(mode, exc.configMissing);
110
- throw new CliError(title, message, {
111
- exitCode: EXIT_INPUT,
112
- required: [
113
- mode === "local"
114
- ? "SCRIPTCTL_OUTPUT_DIR"
115
- : "AWB_BASE_URL, LINGJING_AWB_ACCESS_KEY, and SANDBOX_PROJECT_GROUP_NO (or --project-group-no)",
116
- ],
117
- received: [exc.message, `configMissing: ${exc.configMissing ?? "unknown"}`],
118
- nextSteps: ["Run `scriptctl doctor` to identify missing configuration."],
119
- errorCode: "SCRIPT_API_CONFIG_MISSING",
120
- });
121
- }
122
- // Belt-and-suspenders: anything else that escapes RemoteScriptOutputStore.fromEnv
123
- // (a TypeError on a malformed gateway URL, future programmer errors) still
124
- // surfaces as a clean CliError envelope rather than the generic
125
- // "RUNTIME FAILED: Unexpected error" path in cli.ts.
126
- const raw = exc instanceof Error ? exc.message : String(exc);
127
- throw new CliError(mode === "local"
128
- ? "SCRIPT API BLOCKED: local output dir not configured"
129
- : "SCRIPT API BLOCKED: gateway not configured", mode === "local"
130
- ? "Local output dir is not configured for this environment."
131
- : "Gateway is not configured for this environment.", {
132
- exitCode: EXIT_INPUT,
133
- required: [mode === "local" ? "valid local output dir" : "valid gateway configuration"],
134
- received: [raw],
135
- nextSteps: ["Run `scriptctl doctor` to identify missing configuration."],
136
- errorCode: "SCRIPT_API_CONFIG_MISSING",
137
- });
138
- }
139
- }
140
- function describeConfigMissing(mode, slot) {
141
- // Mode wins over slot — if the caller is in local mode, the failure is
142
- // unambiguously about the output dir even if the underlying error tagged
143
- // the slot differently.
144
- // NOTE: this early return short-circuits the exhaustive switch below. New
145
- // ConfigMissingSlot values for remote-mode callers are guarded by the
146
- // switch (TS will flag a missing case); values intended for local-mode-only
147
- // callers must be handled explicitly above this guard.
148
- if (mode === "local") {
149
- return {
150
- title: "SCRIPT API BLOCKED: local output dir missing",
151
- message: "Local output dir is not configured. Set `SCRIPTCTL_OUTPUT_DIR` to a writable directory.",
152
- };
153
- }
154
- // Unknown / unspecified failures in remote mode default to the gateway
155
- // bucket — keeps the pre-0.7.0 UX stable.
156
- if (slot === null) {
157
- return {
158
- title: "SCRIPT API BLOCKED: gateway not configured",
159
- message: "Gateway is not configured for this environment.",
160
- };
161
- }
162
- // After the two guards above, `slot` is narrowed to ConfigMissingSlot. TS
163
- // exhaustiveness on the switch means adding a new slot without handling it
164
- // here is a compile error — `assertNever` makes the intent explicit.
165
- switch (slot) {
166
- case "output":
167
- return {
168
- title: "SCRIPT API BLOCKED: local output dir missing",
169
- message: "Local output dir is not configured. Set `SCRIPTCTL_OUTPUT_DIR` to a writable directory.",
170
- };
171
- case "project":
172
- return {
173
- title: "SCRIPT API BLOCKED: project identifier missing",
174
- message: "Project identifier is not configured. Pass `--project-group-no <id>` or configure the project for this environment.",
175
- };
176
- case "access":
177
- return {
178
- title: "SCRIPT API BLOCKED: access credential missing",
179
- message: "Access credential is not configured for this environment.",
180
- };
181
- case "gateway":
182
- return {
183
- title: "SCRIPT API BLOCKED: gateway not configured",
184
- message: "Gateway is not configured for this environment.",
185
- };
186
- default:
187
- return assertNever(slot);
188
- }
189
- }
190
- function assertNever(x) {
191
- throw new Error(`describeConfigMissing: unhandled ConfigMissingSlot ${JSON.stringify(x)}`);
192
- }
193
- export function apiErrorToCli(title, exc) {
194
- const rawMessage = exc.message || "script-output API failed";
195
- // Use the gateway's own canonical Chinese keywords as the only signal. Free
196
- // text like "revision" or "not found" can leak in from upstream proxies,
197
- // DNS errors ("name not found"), or HTTP 404 routing mismatches — matching on
198
- // those substrings would misroute auth/network failures into the conflict /
199
- // missing-script UX. The fake-gateway contract is `code:9001` + Chinese
200
- // keyword for both conflict and missing, so the keyword is precise enough.
201
- const isConflict = rawMessage.includes("版本冲突");
202
- const isMissing = rawMessage.includes("剧本不存在");
203
- const publicMessage = isConflict
204
- ? "Script has been updated by another revision; reload and retry."
205
- : isMissing
206
- ? "Script not found on the gateway."
207
- : "Gateway request failed.";
208
- return new CliError(title, publicMessage, {
209
- exitCode: exc.status !== null && exc.status >= 500 ? EXIT_RUNTIME : EXIT_INPUT,
210
- required: ["successful script-output gateway request"],
211
- received: [rawMessage, `status: ${exc.status ?? "<unknown>"}`],
212
- nextSteps: [
213
- isConflict
214
- ? "Reload the latest script revision and retry."
215
- : isMissing
216
- ? "Run `scriptctl direct export` to publish a script first."
217
- : "Run `scriptctl doctor` to verify gateway configuration; retry if it was transient.",
218
- ],
219
- errorCode: isConflict
220
- ? "SCRIPT_REVISION_CONFLICT"
221
- : isMissing
222
- ? "SCRIPT_NOT_FOUND"
223
- : "SCRIPT_API_FAILED",
224
- });
225
- }
226
- export async function currentRevisionOrZero(client) {
227
- try {
228
- const revision = await client.getRevision();
229
- if (!revision)
230
- return 0;
231
- return Number(revision["revision"] ?? 0);
232
- }
233
- catch (exc) {
234
- if (exc instanceof ScriptOutputApiError) {
235
- throw apiErrorToCli("SCRIPT API BLOCKED: Revision query failed", exc);
236
- }
237
- throw exc;
238
- }
239
- }
240
- async function loadRemoteScript(opts, workspace) {
241
- const client = scriptOutputClient(opts);
242
- let script;
243
- let revision;
244
- try {
245
- script = await client.getScript();
246
- revision = await client.getRevision();
247
- }
248
- catch (exc) {
249
- if (exc instanceof ScriptOutputApiError) {
250
- throw apiErrorToCli("SCRIPT API BLOCKED: Script query failed", exc);
251
- }
252
- throw exc;
253
- }
254
- if (script === null) {
255
- throw new CliError("SCRIPT BLOCKED: Final script not found", "No script has been published for this project.", {
256
- exitCode: EXIT_INPUT,
257
- required: ["existing script-output project document"],
258
- received: [`projectGroupNo=${client.projectGroupNo}`],
259
- nextSteps: ["Run `scriptctl direct export` first, or pass `--script-path` to edit a local intermediate script JSON."],
260
- errorCode: "SCRIPT_NOT_FOUND",
261
- });
262
- }
263
- if (!isDict(script)) {
264
- throw new CliError("SCRIPT BLOCKED: script root invalid", "script root invalid.", {
265
- exitCode: EXIT_USAGE,
266
- required: ["script root object"],
267
- received: [typeof script],
268
- nextSteps: ["Replace the final script with a valid script document object."],
269
- errorCode: "SCRIPT_ROOT_INVALID",
270
- });
271
- }
272
- return new ScriptEditSession({
273
- workspace,
274
- script,
275
- client,
276
- projectGroupNo: client.projectGroupNo,
277
- revision: Number((revision ?? {})["revision"] ?? 0),
278
- });
279
- }
280
- async function loadScriptForEdit(opts) {
281
- const workspace = strOf(opts["workspace_path"] || "workspace");
282
- // --draft is the blessed entrance for editing the direct intermediate draft:
283
- // it resolves to script.initial.json without exposing the storage path to the
284
- // agent. It is mutually exclusive with the raw --script-path and the DB-mode
285
- // --project-group-no (both name a different target).
286
- const draft = Boolean(opts["draft"]);
287
- let draftPath = null;
288
- if (draft) {
289
- if (opts["script_path"]) {
290
- throw new CliError("SCRIPT BLOCKED: --draft conflicts with --script-path", "--draft conflicts with --script-path.", {
291
- exitCode: EXIT_USAGE,
292
- required: ["either --draft (edit the direct draft) or --script-path (edit a named local script), not both"],
293
- received: ["--draft and --script-path"],
294
- nextSteps: ["Drop one of the two flags and retry."],
295
- errorCode: "DRAFT_FLAG_CONFLICT",
296
- });
297
- }
298
- if (opts["project_group_no"]) {
299
- throw new CliError("SCRIPT BLOCKED: --draft conflicts with --project-group-no", "--draft conflicts with --project-group-no.", {
300
- exitCode: EXIT_USAGE,
301
- required: ["either --draft (edit the local direct draft) or --project-group-no (edit the DB script), not both"],
302
- received: ["--draft and --project-group-no"],
303
- nextSteps: ["Drop one of the two flags and retry."],
304
- errorCode: "DRAFT_FLAG_CONFLICT",
305
- });
306
- }
307
- draftPath = path.join(directDir(workspace), "script.initial.json");
308
- if (!exists(draftPath)) {
309
- throw new CliError("SCRIPT BLOCKED: Draft script not found", "Direct draft script not found.", {
310
- exitCode: EXIT_INPUT,
311
- required: ["a direct draft produced by `direct init`"],
312
- received: [draftPath],
313
- nextSteps: ["Run `scriptctl direct init` first, then edit with --draft."],
314
- errorCode: "SCRIPT_NOT_FOUND",
315
- });
316
- }
317
- }
318
- // Resolve the local file from --draft or --script-path WITHOUT mutating opts:
319
- // commandAlias loads twice with the same opts, and writing script_path back
320
- // would make the second load see a phantom --script-path and reject --draft.
321
- if (!draftPath && !opts["script_path"]) {
322
- return loadRemoteScript(opts, workspace);
323
- }
324
- const p = draftPath ?? scriptJsonPath(opts);
325
- if (!exists(p)) {
326
- throw new CliError("SCRIPT BLOCKED: Local script file not found", "Local script file not found.", {
327
- exitCode: EXIT_INPUT,
328
- required: ["--script-path existing local script JSON"],
329
- received: [p],
330
- nextSteps: ["Pass --script-path to an existing local intermediate script JSON, or omit --script-path to use DB-backed script-output."],
331
- errorCode: "SCRIPT_NOT_FOUND",
332
- });
333
- }
334
- let data;
335
- try {
336
- data = readJson(p);
337
- }
338
- catch (exc) {
339
- throw new CliError("SCRIPT BLOCKED: script JSON invalid", "script JSON invalid.", {
340
- exitCode: EXIT_INPUT,
341
- required: ["valid JSON"],
342
- received: [`${p}: ${exc.message}`],
343
- nextSteps: ["Fix the local script JSON syntax before editing."],
344
- errorCode: "SCRIPT_JSON_INVALID",
345
- });
346
- }
347
- if (!isDict(data)) {
348
- throw new CliError("SCRIPT BLOCKED: script root invalid", "script root invalid.", {
349
- exitCode: EXIT_USAGE,
350
- required: ["script root object"],
351
- received: [Array.isArray(data) ? "array" : typeof data],
352
- nextSteps: ["Use a valid script document object."],
353
- errorCode: "SCRIPT_ROOT_INVALID",
354
- });
355
- }
356
- return new ScriptEditSession({ workspace, scriptPath: p, script: data });
357
- }
358
- function validateSession(session, opts = {}) {
359
- const requireSource = opts.requireSource ?? false;
360
- if (!session.remote) {
361
- return validateScript(session.workspace, session.scriptPath, { requireSource });
362
- }
363
- const tmpPath = path.join(os.tmpdir(), `scriptctl-db-script-${randomUUID()}.json`);
364
- try {
365
- fs.writeFileSync(tmpPath, JSON.stringify(session.script, null, 2) + "\n", "utf-8");
366
- const validation = validateScript(session.workspace, tmpPath, { requireSource });
367
- validation["script_path"] = session.artifactLabel;
368
- writeJson(path.join(directDir(session.workspace), "validation.json"), validation);
369
- return validation;
370
- }
371
- finally {
372
- try {
373
- fs.unlinkSync(tmpPath);
374
- }
375
- catch {
376
- // ignore
377
- }
378
- }
379
- }
380
- function validationIssuePath(issue) {
381
- if (issue["path"])
382
- return strOf(issue["path"]);
383
- const parts = [];
384
- if (issue["episode"] !== null && issue["episode"] !== undefined)
385
- parts.push(strOf(issue["episode"]));
386
- if (issue["scene"] !== null && issue["scene"] !== undefined)
387
- parts.push(strOf(issue["scene"]));
388
- if (issue["action_index"] !== null && issue["action_index"] !== undefined)
389
- parts.push(`action[${issue["action_index"]}]`);
390
- return parts.length > 0 ? parts.join("/") : null;
391
- }
392
- function validationIssueSeverity(raw) {
393
- const severity = strOf(raw || "info");
394
- if (severity === "blocking" || severity === "error")
395
- return "error";
396
- if (severity === "warning")
397
- return "warning";
398
- return "info";
399
- }
400
- function validationToApiPayload(validation) {
401
- const issues = asList(validation["issues"]);
402
- const status = validation["passed"] ? "valid" : "invalid";
403
- const summary = {
404
- passed: Boolean(validation["passed"]),
405
- hasBlocking: Boolean(validation["has_blocking"]),
406
- stats: validation["stats"] ?? {},
407
- issueCount: issues.length,
408
- };
409
- const apiIssues = [];
410
- for (const issue of issues) {
411
- if (!isDict(issue))
412
- continue;
413
- const payload = { ...issue };
414
- for (const key of ["severity", "code", "path", "summary", "repair_hint"])
415
- delete payload[key];
416
- apiIssues.push({
417
- severity: validationIssueSeverity(issue["severity"]),
418
- code: strOf(issue["code"] || "SCRIPT_VALIDATION_ISSUE"),
419
- path: validationIssuePath(issue),
420
- summary: strOf(issue["summary"]),
421
- repairHint: issue["repair_hint"],
422
- payload,
423
- });
424
- }
425
- return [status, summary, apiIssues];
426
- }
427
- async function syncValidationResult(session, validation, revision) {
428
- if (!session.remote || session.client === null)
429
- return;
430
- const targetRevision = Number(revision ?? session.revision ?? 0);
431
- if (targetRevision <= 0)
432
- return;
433
- const [status, summary, issues] = validationToApiPayload(validation);
434
- try {
435
- await session.client.saveValidationResult({
436
- revision: targetRevision,
437
- validationStatus: status,
438
- validationSummary: summary,
439
- issues,
440
- });
441
- }
442
- catch (exc) {
443
- if (exc instanceof ScriptOutputApiError) {
444
- throw apiErrorToCli("SCRIPT API BLOCKED: Validation sync failed", exc);
445
- }
446
- throw exc;
447
- }
448
- }
449
- // Idempotency key for gateway writes. Returns the agent-provided --request-id
450
- // when present, otherwise derives a deterministic key from (op, post-mutation
451
- // script content). baseRevision is intentionally NOT in the hash: in a
452
- // partial-success retry the client reloads at a new baseRevision but the
453
- // post-mutation script is identical, so the hash matches and the gateway can
454
- // dedupe. The cost of including baseRevision (an old version of this code)
455
- // was that retries produced different ids and committed redundant no-op
456
- // revisions to the gateway history.
457
- function requestIdForScriptWrite(opts, op, payload) {
458
- const explicit = strOf(opts["request_id"]).trim();
459
- if (explicit)
460
- return explicit;
461
- const canonical = JSON.stringify(sortDeep({
462
- op,
463
- script: payload.script,
464
- }));
465
- return `scriptctl:${op}:${sha256Text(canonical)}`;
466
- }
467
- async function saveScriptSession(session, opts, op) {
468
- if (!session.remote) {
469
- writeJson(session.scriptPath, session.script);
470
- return [null, false];
471
- }
472
- if (session.client === null)
473
- throw new Error("remote script session missing client");
474
- const baseRevision = Number(session.revision ?? 0);
475
- const requestId = requestIdForScriptWrite(opts, op, { script: session.script });
476
- let res;
477
- try {
478
- res = await session.client.replaceScript({
479
- requestId,
480
- baseRevision,
481
- script: session.script,
482
- source: "ctl",
483
- });
484
- }
485
- catch (exc) {
486
- if (exc instanceof ScriptOutputApiError) {
487
- throw apiErrorToCli("SCRIPT API BLOCKED: Script write failed", exc);
488
- }
489
- throw exc;
490
- }
491
- session.revision = Number(res["revision"] ?? session.revision ?? 0);
492
- return [session.revision, Boolean(res["idempotent"])];
493
- }
494
- function scriptSourceText(workspace) {
495
- const sourcePath = path.join(workspace, "source.txt");
496
- return exists(sourcePath) ? readText(sourcePath) : "";
497
- }
498
- function patchOperationsFromPayload(payload) {
499
- let operations;
500
- if (isList(payload)) {
501
- operations = payload;
502
- }
503
- else if (isDict(payload) && isList(payload["ops"])) {
504
- operations = payload["ops"];
505
- }
506
- else if (isDict(payload) && isList(payload["operations"])) {
507
- operations = payload["operations"];
508
- }
509
- else if (isDict(payload)) {
510
- operations = [payload];
511
- }
512
- else {
513
- throw new CliError("PATCH BLOCKED: Patch schema invalid", "Patch schema invalid.", {
514
- exitCode: EXIT_USAGE,
515
- required: ["object, array, object with ops[], or object with operations[]"],
516
- received: [Array.isArray(payload) ? "array" : typeof payload],
517
- nextSteps: ["Fix patch JSON and rerun patch."],
518
- });
519
- }
520
- if (operations.some((op) => !isDict(op))) {
521
- throw new CliError("PATCH BLOCKED: Patch operation invalid", "Patch operation invalid.", {
522
- exitCode: EXIT_USAGE,
523
- required: ["each operation must be an object"],
524
- received: [JSON.stringify(operations).slice(0, 500)],
525
- nextSteps: ["Fix patch JSON and rerun patch."],
526
- });
527
- }
528
- return operations;
529
- }
530
- // ---------------------------------------------------------------------------
531
- // commandScriptValidate / commandScriptInspect
532
- // ---------------------------------------------------------------------------
533
- export async function commandScriptValidate(opts) {
534
- const workspace = strOf(opts["workspace_path"] || "workspace");
535
- const session = await loadScriptForEdit(opts);
536
- const validation = validateSession(session);
537
- await syncValidationResult(session, validation);
538
- const stats = validation["stats"] ?? {};
539
- const passed = Boolean(validation["passed"]);
540
- const report = {
541
- title: passed ? "SCRIPT VALIDATE PASSED" : "SCRIPT VALIDATE NEEDS REPAIR",
542
- op: "script.validate",
543
- changed: false,
544
- summary: `episodes=${stats["episodes"] ?? 0}, scenes=${stats["scenes"] ?? 0}, actions=${stats["actions"] ?? 0}`,
545
- result: [
546
- `episodes: ${stats["episodes"] ?? 0}`,
547
- `scenes: ${stats["scenes"] ?? 0}`,
548
- `actions: ${stats["actions"] ?? 0}`,
549
- `speakers: ${stats["speakers"] ?? 0}`,
550
- ],
551
- issues: summarizeIssues(asList(validation["issues"])),
552
- artifacts: [session.artifactLabel, path.join(directDir(workspace), "validation.json")],
553
- next: [passed ? "Continue editing the final script." : "Inspect issues and apply a script patch."],
554
- };
555
- return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
556
- }
557
- function actionAddress(epId, sceneId, idx) {
558
- return `${epId}/${sceneId}#${idx}`;
559
- }
560
- function formatInspectAction(epId, sceneId, idx, action) {
561
- const addr = actionAddress(epId, sceneId, idx);
562
- const type = strOf(action["type"]).trim() || "action";
563
- const delivery = strOf(action["delivery"]).trim();
564
- const tag = delivery ? `${type} ${delivery}` : type;
565
- // Prefer the speaker_id (concrete) over actor_id (asset), and skip both when
566
- // the action is narrative (no speaker). Format kept on a single line so agents
567
- // can grep the output.
568
- const speakerId = strOf(action["speaker_id"]).trim();
569
- const actorId = strOf(action["actor_id"]).trim();
570
- const who = speakerId || actorId;
571
- let content = strOf(action["content"]);
572
- if (!content) {
573
- const lines = asList(action["lines"]);
574
- if (lines.length > 0)
575
- content = lines.map((l) => strOf(l["content"])).join(" / ");
576
- }
577
- const prefix = who ? `${addr} [${tag} ${who}]` : `${addr} [${tag}]`;
578
- return content ? `${prefix} ${content}` : prefix;
579
- }
580
- // ---------------------------------------------------------------------------
581
- // command_script_patch
582
- // ---------------------------------------------------------------------------
583
- export async function commandScriptPatch(opts) {
584
- // --schema: dump op schemas as JSON; no script load, no file required.
585
- // `--schema <op>` narrows to a single op; bare `--schema` dumps all.
586
- const schemaOpt = opts["schema"];
587
- if (schemaOpt !== undefined && schemaOpt !== false) {
588
- const specificOp = typeof schemaOpt === "string" && schemaOpt !== "" ? schemaOpt : null;
589
- if (specificOp) {
590
- const entry = PATCH_OP_SCHEMA[specificOp];
591
- if (!entry) {
592
- throw new CliError("SCRIPT PATCH BLOCKED: Unknown op", "Unknown op.", {
593
- exitCode: EXIT_USAGE,
594
- required: ["existing dot-style op name (e.g. asset.rename)"],
595
- received: [specificOp],
596
- nextSteps: ["Run `scriptctl patch --schema` (no value) to list all ops."],
597
- errorCode: "PATCH_OP_UNKNOWN",
598
- });
599
- }
600
- const report = {
601
- title: `PATCH OP SCHEMA: ${specificOp}`,
602
- op: "patch.schema",
603
- changed: false,
604
- summary: entry.description,
605
- result: [
606
- `op: ${specificOp}`,
607
- `required: ${entry.required.join(", ") || "(none)"}`,
608
- `optional: ${entry.optional.join(", ") || "(none)"}`,
609
- ],
610
- body: JSON.stringify({ op: specificOp, ...entry }, null, 2),
611
- next: ["Construct a patch file with these fields and run `scriptctl patch <file>`."],
612
- };
613
- return [report, EXIT_OK];
614
- }
615
- const ops = Object.keys(PATCH_OP_SCHEMA).sort();
616
- const report = {
617
- title: "PATCH OP SCHEMA",
618
- op: "patch.schema",
619
- changed: false,
620
- summary: `${ops.length} dot-style ops supported`,
621
- result: ops.map((name) => {
622
- const e = PATCH_OP_SCHEMA[name];
623
- return `${name}: required=[${e.required.join(",")}] optional=[${e.optional.join(",")}]`;
624
- }),
625
- body: JSON.stringify(PATCH_OP_SCHEMA, null, 2),
626
- next: ["Use `scriptctl patch --schema <op>` for a single op, or write a patch file and run `scriptctl patch <file>`."],
627
- };
628
- return [report, EXIT_OK];
629
- }
630
- const workspace = strOf(opts["workspace_path"] || "workspace");
631
- const patchPath = strOf(opts["patch"]);
632
- if (!patchPath) {
633
- throw new CliError("SCRIPT PATCH BLOCKED: Patch file missing", "Patch file missing.", {
634
- exitCode: EXIT_USAGE,
635
- required: ["<file> positional argument"],
636
- received: ["<empty>"],
637
- nextSteps: ["Pass a patch JSON file, or use --schema to inspect op definitions."],
638
- errorCode: "PATCH_FILE_MISSING",
639
- });
640
- }
641
- if (!exists(patchPath)) {
642
- throw new CliError("SCRIPT PATCH BLOCKED: Patch file not found", "Patch file not found.", {
643
- exitCode: EXIT_INPUT,
644
- required: ["existing patch JSON file"],
645
- received: [patchPath],
646
- nextSteps: ["Write patch JSON and rerun."],
647
- errorCode: "PATCH_NOT_FOUND",
648
- });
649
- }
650
- let payload;
651
- try {
652
- payload = readJson(patchPath);
653
- }
654
- catch (exc) {
655
- throw new CliError("SCRIPT PATCH BLOCKED: Patch JSON invalid", "Patch JSON invalid.", {
656
- exitCode: EXIT_USAGE,
657
- required: ["valid JSON patch"],
658
- received: [`${patchPath}: ${exc.message}`],
659
- nextSteps: ["Fix patch JSON and rerun."],
660
- errorCode: "PATCH_JSON_INVALID",
661
- });
662
- }
663
- const operations = patchOperationsFromPayload(payload);
664
- const dryRun = Boolean(opts["dry_run"]);
665
- const session = await loadScriptForEdit(opts);
666
- const script = session.script;
667
- const applied = applyPatchOperations(script, scriptSourceText(workspace), operations);
668
- if (dryRun) {
669
- // Validate the in-memory mutated script WITHOUT calling saveScriptSession.
670
- // Run validate but skip syncValidationResult (which writes to remote) too.
671
- const validation = validateSession(session);
672
- const passed = Boolean(validation["passed"]);
673
- const report = {
674
- title: passed ? "SCRIPT PATCH DRY-RUN PASSED" : "SCRIPT PATCH DRY-RUN: Validation needs repair",
675
- op: "script.patch.dry-run",
676
- changed: false,
677
- summary: `would apply ${applied.length} operation(s) — no write performed`,
678
- warnings: passed ? [] : summarizeIssues(asList(validation["issues"])),
679
- result: [
680
- `operations: ${applied.length}`,
681
- `validation: ${passed ? "would pass" : "would need repair"}`,
682
- "dry-run: no remote/local write performed",
683
- ],
684
- issues: summarizeIssues(asList(validation["issues"])),
685
- artifacts: [session.artifactLabel],
686
- next: [passed ? "Re-run without --dry-run to commit." : "Resolve validation issues before committing."],
687
- };
688
- return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
689
- }
690
- const [newRevision, idempotent] = await saveScriptSession(session, opts, applied.length === 1 ? applied[0] : "script.patch");
691
- const validation = validateSession(session);
692
- await syncValidationResult(session, validation, newRevision);
693
- const passed = Boolean(validation["passed"]);
694
- const resultLines = [
695
- `operations: ${applied.length}`,
696
- `validation: ${passed ? "passed" : "needs repair"}`,
697
- ];
698
- if (session.remote) {
699
- resultLines.push(`revision: ${newRevision}`);
700
- resultLines.push(`idempotent: ${String(idempotent).toLowerCase()}`);
701
- }
702
- const report = {
703
- title: passed ? "SCRIPT PATCH APPLIED" : "SCRIPT PATCH APPLIED: Validation needs repair",
704
- op: applied.length === 1 ? applied[0] : "script.patch",
705
- changed: applied.length > 0,
706
- summary: `applied ${applied.length} operation(s)`,
707
- warnings: passed ? [] : summarizeIssues(asList(validation["issues"])),
708
- result: resultLines,
709
- issues: summarizeIssues(asList(validation["issues"])),
710
- artifacts: [session.artifactLabel, path.join(directDir(workspace), "validation.json")],
711
- next: [passed ? "Continue editing the final script." : "Inspect issues and apply another patch."],
712
- };
713
- return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
714
- }
715
- // ---------------------------------------------------------------------------
716
- // Single-op helpers
717
- // ---------------------------------------------------------------------------
718
- async function applySingleScriptOp(opts, op) {
719
- const workspace = strOf(opts["workspace_path"] || "workspace");
720
- const session = await loadScriptForEdit(opts);
721
- const script = session.script;
722
- const applied = applyPatchOperations(script, scriptSourceText(workspace), [op]);
723
- const opName = applied[0] ?? strOf(op["op"]);
724
- const [newRevision, idempotent] = await saveScriptSession(session, opts, opName);
725
- const validation = validateSession(session);
726
- await syncValidationResult(session, validation, newRevision);
727
- const passed = Boolean(validation["passed"]);
728
- const resultLines = [
729
- `operation: ${opName}`,
730
- `validation: ${passed ? "passed" : "needs repair"}`,
731
- ];
732
- if (session.remote) {
733
- resultLines.push(`revision: ${newRevision}`);
734
- resultLines.push(`idempotent: ${String(idempotent).toLowerCase()}`);
735
- }
736
- const report = {
737
- title: passed ? "SCRIPT OP APPLIED" : "SCRIPT OP APPLIED: Validation needs repair",
738
- op: opName,
739
- changed: applied.length > 0,
740
- summary: summarizeScriptOp(script, op),
741
- warnings: passed ? [] : summarizeIssues(asList(validation["issues"])),
742
- result: resultLines,
743
- issues: summarizeIssues(asList(validation["issues"])),
744
- artifacts: [session.artifactLabel, path.join(directDir(workspace), "validation.json")],
745
- next: [passed ? "Continue editing the final script." : "Inspect issues and apply another patch."],
746
- };
747
- return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
748
- }
749
- function summarizeScriptOp(_script, op) {
750
- const kind = strOf(op["op"]);
751
- if (kind === "actor.add")
752
- return `已新增人物 ${op["name"] || op["actor_name"]}${op["role_type"] ? `(${op["role_type"]})` : ""}`;
753
- if (kind === "location.add")
754
- return `已新增场景地点 ${op["name"] || op["location_name"]}`;
755
- if (kind === "prop.add")
756
- return `已新增道具 ${op["name"] || op["prop_name"]}`;
757
- if (kind === "episode.add")
758
- return `已新增分集${op["episode_id"] ? ` ${op["episode_id"]}` : "(自动编号)"}${op["title"] ? `:${op["title"]}` : ""}`;
759
- if (kind === "state.add")
760
- return `已为 ${op["target"]} 新增状态 ${op["state_id"]}: ${op["name"] || op["state_name"]}`;
761
- if (kind === "action.state.change")
762
- return `已在 ${op["at"]} 为 ${op["target"]} 设置状态切换到 ${op["to"] || op["to_state_id"]}`;
763
- if (kind.startsWith("state."))
764
- return `已执行 ${kind}: ${op["target"]}`;
765
- if (kind.startsWith("dialogue."))
766
- return `已执行 ${kind}: ${op["at"]}`;
767
- return `已执行 ${kind}`;
768
- }
769
- async function commandStateRefs(opts, plan = false) {
770
- const session = await loadScriptForEdit(opts);
771
- const script = session.script;
772
- const args = asList(opts["_args"]);
773
- const [targetKind, targetId, stateId] = parseStateTarget(args[0] ?? "");
774
- // Verify state exists (will throw if not)
775
- const _asset = (() => {
776
- for (const asset of asList(script[`${targetKind}s`])) {
777
- if (strOf(asset[`${targetKind}_id`]) === targetId) {
778
- for (const st of asList(asset["states"])) {
779
- if (strOf(st["state_id"]) === stateId)
780
- return st;
781
- }
782
- }
783
- }
784
- throw new CliError("SCRIPT OP BLOCKED: State not found", "State not found.", {
785
- exitCode: EXIT_USAGE,
786
- required: ["existing state_id on the target asset"],
787
- received: [`${targetKind}:${targetId}/${stateId}`],
788
- nextSteps: ["Inspect states and fix the target."],
789
- errorCode: "STATE_NOT_FOUND",
790
- });
791
- })();
792
- void _asset;
793
- const refs = collectStateRefs(script, targetKind, targetId, stateId);
794
- const report = {
795
- title: plan ? "SCRIPT STATE DELETE PLAN" : "SCRIPT STATE REFS",
796
- op: plan ? "state.delete-plan" : "state.refs",
797
- changed: false,
798
- summary: `${targetKind}:${targetId}/${stateId} refs=${refs.length}`,
799
- result: refs.length > 0 ? refs.map((r) => JSON.stringify(r)) : ["No references."],
800
- refs: refs,
801
- artifacts: [session.artifactLabel],
802
- next: plan && refs.length > 0
803
- ? ["Choose replace/remove strategy. Do not delete silently when references affect story continuity."]
804
- : [plan ? "State can be deleted directly." : "Use delete-plan before deleting a state."],
805
- };
806
- return [report, EXIT_OK];
807
- }
808
- // ---------------------------------------------------------------------------
809
- // command_export (direct export)
810
- // ---------------------------------------------------------------------------
811
- export async function commandExport(opts) {
812
- const workspace = strOf(opts["workspace_path"] || "workspace");
813
- const force = Boolean(opts["force"]);
814
- const scriptPath = path.join(directDir(workspace), "script.initial.json");
815
- if (!exists(scriptPath)) {
816
- throw new CliError("EXPORT BLOCKED: script.initial.json not found", "script.initial.json not found.", {
817
- exitCode: EXIT_INPUT,
818
- required: ["workspace/draft/scriptctl/direct/script.initial.json"],
819
- received: [scriptPath],
820
- nextSteps: ["Run scriptctl direct init first."],
821
- });
822
- }
823
- const state = readRunState(workspace);
824
- const provider = strOf(state["provider"]);
825
- if (provider === "mock" && process.env.SCRIPTCTL_ALLOW_MOCK_EXPORT !== "1") {
826
- const report = {
827
- title: "EXPORT BLOCKED: Mock provider result",
828
- result: ["script.initial.json was produced by --provider mock and was not exported."],
829
- artifacts: [path.join(directDir(workspace), "run_state.json")],
830
- next: ["Rerun init with --provider anthropic for deliverable conversion."],
831
- };
832
- return [report, EXIT_NEEDS_AGENT];
833
- }
834
- const validation = validateScript(workspace, scriptPath);
835
- const blockingOrError = Boolean(validation["has_blocking"]) ||
836
- asList(validation["issues"]).some((it) => isDict(it) && (it["severity"] === "blocking" || it["severity"] === "error"));
837
- if (!validation["passed"] && (!force || blockingOrError)) {
838
- const title = force
839
- ? "EXPORT BLOCKED: Validation errors require repair"
840
- : "EXPORT BLOCKED: Validation needs agent repair";
841
- const report = {
842
- title,
843
- result: ["script.initial.json was not exported."],
844
- artifacts: [path.join(directDir(workspace), "validation.json")],
845
- issues: summarizeIssues(asList(validation["issues"])),
846
- next: ["Inspect issues and apply structured patches, then validate/export."],
847
- };
848
- return [report, EXIT_NEEDS_AGENT];
849
- }
850
- let script;
851
- try {
852
- script = readJson(scriptPath);
853
- }
854
- catch (exc) {
855
- throw new CliError("EXPORT BLOCKED: script.initial.json invalid", "script.initial.json invalid.", {
856
- exitCode: EXIT_INPUT,
857
- required: ["valid script.initial.json"],
858
- received: [`${scriptPath}: ${exc.message}`],
859
- nextSteps: ["Fix script.initial.json or rerun direct init."],
860
- errorCode: "SCRIPT_JSON_INVALID",
861
- });
862
- }
863
- if (!isDict(script)) {
864
- throw new CliError("EXPORT BLOCKED: script root invalid", "script root invalid.", {
865
- exitCode: EXIT_USAGE,
866
- required: ["script root object"],
867
- received: [Array.isArray(script) ? "array" : typeof script],
868
- nextSteps: ["Use a valid script document object."],
869
- errorCode: "SCRIPT_ROOT_INVALID",
870
- });
871
- }
872
- const client = scriptOutputClient(opts);
873
- const baseRevision = await currentRevisionOrZero(client);
874
- // Sorted-keys serialization mirrors Python json.dumps(sort_keys=True, separators=(",",":"))
875
- const sortedScript = sortDeep(script);
876
- const scriptHash = sha256Text(JSON.stringify(sortedScript));
877
- const requestId = strOf(opts["request_id"]).trim() || `scriptctl-direct-export:${scriptHash}`;
878
- let replaceRes;
879
- try {
880
- replaceRes = await client.replaceScript({
881
- requestId,
882
- baseRevision,
883
- script,
884
- source: "ctl",
885
- });
886
- }
887
- catch (exc) {
888
- if (exc instanceof ScriptOutputApiError) {
889
- throw apiErrorToCli("SCRIPT API BLOCKED: Export write failed", exc);
890
- }
891
- throw exc;
892
- }
893
- const revision = Number(replaceRes["revision"] ?? 0);
894
- const remoteSession = new ScriptEditSession({
895
- workspace,
896
- script,
897
- client,
898
- projectGroupNo: client.projectGroupNo,
899
- revision,
900
- });
901
- await syncValidationResult(remoteSession, validation, revision);
902
- const outputLabel = remoteSession.artifactLabel;
903
- updateRunState(workspace, { status: "exported", output_path: outputLabel });
904
- const report = {
905
- title: "EXPORT COMPLETE: Final script stored in DB",
906
- result: [
907
- `validation: ${validation["passed"] ? "passed" : "forced"}`,
908
- `base_revision: ${baseRevision}`,
909
- `revision: ${revision}`,
910
- `idempotent: ${String(Boolean(replaceRes["idempotent"])).toLowerCase()}`,
911
- ],
912
- artifacts: [outputLabel, path.join(directDir(workspace), "validation.json")],
913
- next: ["Proceed to downstream asset or footage stages."],
914
- };
915
- return [report, EXIT_OK];
916
- }
917
- // sortDeep is the canonical helper in domain/direct-core; re-exported here for
918
- // back-compat with existing importers of `usecases/script`.
919
- export { sortDeep };
920
- // ===========================================================================
921
- // 0.6.0 plural-noun query commands (replace `commandScriptInspect --target X`)
922
- // ===========================================================================
923
- //
924
- // Each command is a thin read-only wrapper around the underlying script
925
- // document. They all return a Report shaped like inspect's existing output but
926
- // without the `--target` flag — the verb itself encodes the target. Filters
927
- // use `--in <addr>` (compose with the address parsers) plus per-target
928
- // predicates (--name / --kind / --has / --grep / --context / --type / --actor
929
- // / --speaker / --severity / --code / --has-{actor,location,prop}).
930
- // Resolve the speaker_id → source actor mapping once per call. Used by
931
- // commandActions's --actor filter when the action speaks via a speaker.
932
- function speakerSourceActorMap(script) {
933
- const map = new Map();
934
- for (const s of asList(script["speakers"])) {
935
- const sourceKind = strOf(s["source_kind"]);
936
- if (sourceKind === "actor") {
937
- const sid = strOf(s["speaker_id"]);
938
- const src = strOf(s["source_id"]);
939
- if (sid && src)
940
- map.set(sid, src);
941
- }
942
- }
943
- return map;
944
- }
945
- function speakerIdsInAction(action) {
946
- const ids = new Set();
947
- const direct = strOf(action["speaker_id"]).trim();
948
- if (direct)
949
- ids.add(direct);
950
- for (const sp of asList(action["speakers"])) {
951
- const id = strOf(sp["speaker_id"]).trim();
952
- if (id)
953
- ids.add(id);
954
- }
955
- for (const line of asList(action["lines"])) {
956
- const id = strOf(line["speaker_id"]).trim();
957
- if (id)
958
- ids.add(id);
959
- }
960
- return ids;
961
- }
962
- function actorIdsInAction(action, speakerToActor) {
963
- const ids = new Set();
964
- const direct = strOf(action["actor_id"]).trim();
965
- if (direct)
966
- ids.add(direct);
967
- for (const spkId of speakerIdsInAction(action)) {
968
- const actor = speakerToActor.get(spkId);
969
- if (actor)
970
- ids.add(actor);
971
- }
972
- return ids;
973
- }
974
- function parseInFilter(raw) {
975
- if (!raw)
976
- return { epId: null, sceneId: null, actionIndex: null };
977
- try {
978
- const addr = parseAnyAddress(raw);
979
- if (addr.kind === "action")
980
- return { epId: addr.episodeId, sceneId: addr.sceneId, actionIndex: addr.actionIndex };
981
- if (addr.kind === "scene")
982
- return { epId: addr.episodeId, sceneId: addr.sceneId, actionIndex: null };
983
- if (addr.kind === "episode")
984
- return { epId: addr.episodeId, sceneId: null, actionIndex: null };
985
- }
986
- catch {
987
- // Fall through; raw address not recognized.
988
- }
989
- throw new CliError("QUERY BLOCKED: --in address invalid", "--in address invalid.", {
990
- exitCode: EXIT_USAGE,
991
- required: ["--in: ep_NNN | ep_NNN/scn_NNN | ep_NNN/scn_NNN#idx"],
992
- received: [raw],
993
- nextSteps: ["Pass a recognized address format."],
994
- errorCode: "IN_ADDRESS_INVALID",
995
- });
996
- }
997
- // Build a grep matcher. /pattern/flags → RegExp, otherwise literal substring.
998
- // Pre-flight checks block the common ReDoS shapes before they hit V8's regex
999
- // engine: nested quantifiers like (a+)+, (a*)+, (a*|b*)+ etc. compile fine
1000
- // but hang the process on adversarial input. Since --grep accepts caller-
1001
- // controlled patterns and runs over every action in scope, the safe move is
1002
- // to reject patterns with quantifier-on-group-with-internal-quantifier and
1003
- // cap the pattern length.
1004
- const MAX_GREP_PATTERN_LEN = 256;
1005
- function isDangerousRegex(pattern) {
1006
- if (pattern.length > MAX_GREP_PATTERN_LEN)
1007
- return true;
1008
- // Quantifier ({+,*,?,{n,m}}) outside a group whose interior contains another
1009
- // quantifier — classic catastrophic-backtracking template. The check is a
1010
- // heuristic, not a parser; false positives are tolerated as long as the
1011
- // common ReDoS shapes are blocked.
1012
- return /\([^)]*[*+?{][^)]*\)[*+?{]/.test(pattern);
1013
- }
1014
- function buildGrepMatcher(raw) {
1015
- if (!raw)
1016
- return null;
1017
- const m = /^\/(.+)\/([gimsuy]*)$/.exec(raw);
1018
- if (m) {
1019
- const pattern = m[1];
1020
- if (isDangerousRegex(pattern)) {
1021
- throw new CliError("QUERY BLOCKED: regex unsafe", "regex pattern has nested quantifiers (ReDoS risk) or exceeds the length limit.", {
1022
- exitCode: EXIT_USAGE,
1023
- required: [`pattern length <= ${MAX_GREP_PATTERN_LEN}; no quantifier-on-group-with-internal-quantifier (e.g. (a+)+)`],
1024
- received: [`length=${pattern.length}, sample=${pattern.slice(0, 80)}`],
1025
- nextSteps: ["Rewrite the regex to avoid nested quantifiers, or use a literal substring (drop the /…/ wrapper)."],
1026
- errorCode: "REGEX_UNSAFE",
1027
- });
1028
- }
1029
- try {
1030
- const re = new RegExp(pattern, m[2]);
1031
- return (hay) => re.test(hay);
1032
- }
1033
- catch {
1034
- // Malformed regex — treat as literal.
1035
- }
1036
- }
1037
- return (hay) => hay.includes(raw);
1038
- }
1039
- function buildReport(op, title, lines, artifactLabel, next) {
1040
- return {
1041
- title,
1042
- op,
1043
- changed: false,
1044
- summary: `${lines.length} item(s)`,
1045
- result: lines.length > 0 ? lines : ["No matching items."],
1046
- artifacts: [artifactLabel],
1047
- next,
1048
- };
1049
- }
1050
- // ----- summary --------------------------------------------------------------
1051
- function synopsisPreview(text, limit) {
1052
- const oneLine = text.replace(/\s+/g, " ").trim();
1053
- return oneLine.length > limit ? `${oneLine.slice(0, limit)}…` : oneLine;
1054
- }
1055
- export async function commandSummary(opts) {
1056
- const session = await loadScriptForEdit(opts);
1057
- const script = session.script;
1058
- const episodes = asList(script["episodes"]);
1059
- const scenes = [];
1060
- for (const ep of episodes)
1061
- scenes.push(...asList(ep["scenes"]));
1062
- const actions = [];
1063
- for (const scene of scenes)
1064
- actions.push(...asList(scene["actions"]));
1065
- const lines = [
1066
- `title: ${script["title"] || "-"}`,
1067
- `episodes: ${episodes.length}`,
1068
- `scenes: ${scenes.length}`,
1069
- `actions: ${actions.length}`,
1070
- `actors: ${asList(script["actors"]).length}`,
1071
- `locations: ${asList(script["locations"]).length}`,
1072
- `props: ${asList(script["props"]).length}`,
1073
- `speakers: ${asList(script["speakers"]).length}`,
1074
- ];
1075
- const logline = strOf(script["logline"]).trim();
1076
- if (logline)
1077
- lines.push(`logline: ${logline}`);
1078
- const theme = strOf(script["theme"]).trim();
1079
- if (theme)
1080
- lines.push(`theme: ${theme}`);
1081
- const synopsis = strOf(script["synopsis"]).trim();
1082
- if (synopsis)
1083
- lines.push(`synopsis: ${synopsisPreview(synopsis, 120)}`);
1084
- const mainCharacters = asList(script["main_characters"])
1085
- .map((c) => strOf(c["name"]).trim())
1086
- .filter((n) => n);
1087
- if (mainCharacters.length > 0)
1088
- lines.push(`main characters: ${mainCharacters.slice(0, 6).join(", ")}`);
1089
- 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];
1090
- }
1091
- // ----- episodes -------------------------------------------------------------
1092
- export async function commandEpisodes(opts) {
1093
- const session = await loadScriptForEdit(opts);
1094
- const script = session.script;
1095
- const itemId = strOf(opts["id"]).trim();
1096
- const minChars = parseBound(opts["min_chars"]);
1097
- const maxChars = parseBound(opts["max_chars"]);
1098
- const lines = [];
1099
- for (const ep of asList(script["episodes"])) {
1100
- if (itemId && itemId !== strOf(ep["episode_id"]))
1101
- continue;
1102
- const scenes = asList(ep["scenes"]);
1103
- let actionCount = 0;
1104
- const chars = episodeCharCounts(scenes);
1105
- for (const scene of scenes)
1106
- actionCount += asList(scene["actions"]).length;
1107
- if (minChars !== null && chars.total < minChars)
1108
- continue;
1109
- if (maxChars !== null && chars.total > maxChars)
1110
- continue;
1111
- const epSynopsis = strOf(ep["synopsis"]).trim();
1112
- const synopsisField = epSynopsis ? `, synopsis=${synopsisPreview(epSynopsis, 60)}` : "";
1113
- lines.push(`${ep["episode_id"]}: scenes=${scenes.length}, actions=${actionCount}, chars=${chars.total} (dialogue=${chars.dialogue}, action=${chars.action}), title=${ep["title"] || "-"}${synopsisField}`);
1114
- }
1115
- return [buildReport("query.episodes", "EPISODES", lines, session.artifactLabel, ["Use `scriptctl scenes --in <ep>` to drill in, or edit verbs to mutate."]), EXIT_OK];
1116
- }
1117
- // ----- scenes ---------------------------------------------------------------
1118
- function nameMaps(script) {
1119
- const out = { actor: new Map(), location: new Map(), prop: new Map() };
1120
- for (const a of asList(script["actors"]))
1121
- out.actor.set(strOf(a["actor_id"]), strOf(a["actor_name"]));
1122
- for (const l of asList(script["locations"]))
1123
- out.location.set(strOf(l["location_id"]), strOf(l["location_name"]));
1124
- for (const p of asList(script["props"]))
1125
- out.prop.set(strOf(p["prop_id"]), strOf(p["prop_name"]));
1126
- return out;
1127
- }
1128
- function formatScene(epId, scene, names) {
1129
- const ctx = isDict(scene["context"]) ? scene["context"] : {};
1130
- const env = isDict(scene["environment"]) ? scene["environment"] : {};
1131
- const space = strOf(env["space"]) || "-";
1132
- const time = strOf(env["time"]) || "-";
1133
- const locations = asList(ctx["locations"]).map((r) => {
1134
- const id = strOf(r["location_id"]);
1135
- return `${id}(${names.location.get(id) || "?"})`;
1136
- });
1137
- const actors = asList(ctx["actors"]).map((r) => {
1138
- const id = strOf(r["actor_id"]);
1139
- return `${id}(${names.actor.get(id) || "?"})`;
1140
- });
1141
- const props = asList(ctx["props"]).map((r) => {
1142
- const id = strOf(r["prop_id"]);
1143
- return `${id}(${names.prop.get(id) || "?"})`;
1144
- });
1145
- const actionCount = asList(scene["actions"]).length;
1146
- return `${epId}/${scene["scene_id"]} [${space} ${time}] location=${locations.join(",") || "-"} actors=${actors.join(",") || "-"} props=${props.join(",") || "-"} actions=${actionCount}`;
1147
- }
1148
- export async function commandScenes(opts) {
1149
- const session = await loadScriptForEdit(opts);
1150
- const script = session.script;
1151
- const inFilter = parseInFilter(strOf(opts["in"]));
1152
- const hasActor = strOf(opts["has_actor"]).trim();
1153
- const hasLocation = strOf(opts["has_location"]).trim();
1154
- const hasProp = strOf(opts["has_prop"]).trim();
1155
- const names = nameMaps(script);
1156
- const lines = [];
1157
- for (const ep of asList(script["episodes"])) {
1158
- const epId = strOf(ep["episode_id"]);
1159
- if (inFilter.epId && inFilter.epId !== epId)
1160
- continue;
1161
- for (const scene of asList(ep["scenes"])) {
1162
- const sceneId = strOf(scene["scene_id"]);
1163
- if (inFilter.sceneId && inFilter.sceneId !== sceneId)
1164
- continue;
1165
- if (hasActor) {
1166
- const refs = asList((scene["context"] || {})["actors"]);
1167
- if (!refs.some((r) => strOf(r["actor_id"]) === hasActor))
1168
- continue;
1169
- }
1170
- if (hasLocation) {
1171
- const refs = asList((scene["context"] || {})["locations"]);
1172
- if (!refs.some((r) => strOf(r["location_id"]) === hasLocation))
1173
- continue;
1174
- }
1175
- if (hasProp) {
1176
- const refs = asList((scene["context"] || {})["props"]);
1177
- if (!refs.some((r) => strOf(r["prop_id"]) === hasProp))
1178
- continue;
1179
- }
1180
- lines.push(formatScene(epId, scene, names));
1181
- }
1182
- }
1183
- return [buildReport("query.scenes", "SCENES", lines, session.artifactLabel, ["Use `scriptctl actions --in <ep/scn>` to see actions, or edit verbs to mutate."]), EXIT_OK];
1184
- }
1185
- // ----- actions --------------------------------------------------------------
1186
- export async function commandActions(opts) {
1187
- const session = await loadScriptForEdit(opts);
1188
- const script = session.script;
1189
- const inFilter = parseInFilter(strOf(opts["in"]));
1190
- const grep = strOf(opts["grep"]);
1191
- const typeFilter = strOf(opts["type"]).trim();
1192
- const actorFilter = strOf(opts["actor"]).trim();
1193
- const speakerFilter = strOf(opts["speaker"]).trim();
1194
- const hasFilter = strOf(opts["has"]).trim();
1195
- // Whitelist --has: silently accepting unknown values means a typo like
1196
- // "state_changes" (underscore) passed the filter-missing guard but bypassed
1197
- // every concrete predicate, returning the whole script.
1198
- const HAS_FILTERS = new Set(["state-changes", "transition", "lines"]);
1199
- if (hasFilter && !HAS_FILTERS.has(hasFilter)) {
1200
- throw new CliError("QUERY BLOCKED: --has value invalid", "--has value invalid.", {
1201
- exitCode: EXIT_USAGE,
1202
- required: [`--has: one of ${[...HAS_FILTERS].join(", ")}`],
1203
- received: [hasFilter],
1204
- nextSteps: ["Use a supported --has value."],
1205
- errorCode: "HAS_FILTER_INVALID",
1206
- });
1207
- }
1208
- const contextN = (() => {
1209
- const n = opts["context"];
1210
- if (n === undefined || n === null || n === "")
1211
- return 0;
1212
- const parsed = Number(n);
1213
- return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0;
1214
- })();
1215
- const grepMatcher = buildGrepMatcher(grep);
1216
- const speakerToActor = (actorFilter || speakerFilter) ? speakerSourceActorMap(script) : new Map();
1217
- if (!inFilter.epId && !grep && !actorFilter && !speakerFilter && !typeFilter && !hasFilter && inFilter.actionIndex === null) {
1218
- throw new CliError("QUERY BLOCKED: actions filter missing", "actions filter missing.", {
1219
- exitCode: EXIT_USAGE,
1220
- required: ["at least one of --in / --grep / --type / --actor / --speaker / --has"],
1221
- received: ["<empty>"],
1222
- nextSteps: ["Add a filter — dumping every action across all episodes is too large."],
1223
- errorCode: "ACTIONS_FILTER_MISSING",
1224
- });
1225
- }
1226
- const allInScope = [];
1227
- const matchKeys = new Set();
1228
- for (const ep of asList(script["episodes"])) {
1229
- const epId = strOf(ep["episode_id"]);
1230
- if (inFilter.epId && inFilter.epId !== epId)
1231
- continue;
1232
- for (const scene of asList(ep["scenes"])) {
1233
- const sceneId = strOf(scene["scene_id"]);
1234
- if (inFilter.sceneId && inFilter.sceneId !== sceneId)
1235
- continue;
1236
- const actions = asList(scene["actions"]);
1237
- for (let i = 0; i < actions.length; i++) {
1238
- if (inFilter.actionIndex !== null && inFilter.actionIndex !== i)
1239
- continue;
1240
- const a = actions[i];
1241
- if (typeFilter && strOf(a["type"]).trim() !== typeFilter)
1242
- continue;
1243
- if (speakerFilter && !speakerIdsInAction(a).has(speakerFilter))
1244
- continue;
1245
- if (actorFilter && !actorIdsInAction(a, speakerToActor).has(actorFilter))
1246
- continue;
1247
- if (hasFilter === "state-changes" && asList(a["state_changes"]).length === 0)
1248
- continue;
1249
- if (hasFilter === "transition" && !isDict(a["transition_prompt"]))
1250
- continue;
1251
- if (hasFilter === "lines" && asList(a["lines"]).length === 0)
1252
- continue;
1253
- if (grepMatcher) {
1254
- let hay = strOf(a["content"]);
1255
- for (const line of asList(a["lines"]))
1256
- hay += "\n" + strOf(line["content"]);
1257
- if (!grepMatcher(hay))
1258
- continue;
1259
- }
1260
- const key = `${epId}/${sceneId}#${i}`;
1261
- matchKeys.add(key);
1262
- allInScope.push({ epId, sceneId, idx: i, action: a });
1263
- }
1264
- }
1265
- }
1266
- // --context N: expand matches to include neighbors within each scene.
1267
- const lines = [];
1268
- if (contextN > 0 && matchKeys.size > 0) {
1269
- // Walk again, emitting any hit within N of a match (same scene).
1270
- const matchBySceneIdx = new Map();
1271
- for (const k of matchKeys) {
1272
- const [scene, idxStr] = k.split("#");
1273
- const set = matchBySceneIdx.get(scene) ?? new Set();
1274
- set.add(parseInt(idxStr, 10));
1275
- matchBySceneIdx.set(scene, set);
1276
- }
1277
- for (const ep of asList(script["episodes"])) {
1278
- const epId = strOf(ep["episode_id"]);
1279
- for (const scene of asList(ep["scenes"])) {
1280
- const sceneKey = `${epId}/${scene["scene_id"]}`;
1281
- const matchIdxSet = matchBySceneIdx.get(sceneKey);
1282
- if (!matchIdxSet)
1283
- continue;
1284
- const actions = asList(scene["actions"]);
1285
- for (let i = 0; i < actions.length; i++) {
1286
- let withinRange = false;
1287
- for (const m of matchIdxSet) {
1288
- if (Math.abs(i - m) <= contextN) {
1289
- withinRange = true;
1290
- break;
1291
- }
1292
- }
1293
- if (!withinRange)
1294
- continue;
1295
- lines.push(formatInspectAction(epId, strOf(scene["scene_id"]), i, actions[i]));
1296
- }
1297
- }
1298
- }
1299
- }
1300
- else {
1301
- for (const hit of allInScope) {
1302
- lines.push(formatInspectAction(hit.epId, hit.sceneId, hit.idx, hit.action));
1303
- }
1304
- }
1305
- return [buildReport("query.actions", "ACTIONS", lines, session.artifactLabel, ["Use `scriptctl replace <addr>` or `scriptctl type <addr> <type>` etc. to edit."]), EXIT_OK];
1306
- }
1307
- // ----- actors / locations / props / assets ----------------------------------
1308
- function listAssetsByKind(script, kind, opts) {
1309
- const lines = [];
1310
- const [key, idKey, nameKey] = (() => {
1311
- switch (kind) {
1312
- case "actor": return ["actors", "actor_id", "actor_name"];
1313
- case "location": return ["locations", "location_id", "location_name"];
1314
- case "prop": return ["props", "prop_id", "prop_name"];
1315
- }
1316
- })();
1317
- // Build set of ids referenced inside the --in scope if filter is set.
1318
- let scopeIds = null;
1319
- if (opts.inFilter.epId) {
1320
- scopeIds = new Set();
1321
- for (const ep of asList(script["episodes"])) {
1322
- if (strOf(ep["episode_id"]) !== opts.inFilter.epId)
1323
- continue;
1324
- for (const scene of asList(ep["scenes"])) {
1325
- if (opts.inFilter.sceneId && strOf(scene["scene_id"]) !== opts.inFilter.sceneId)
1326
- continue;
1327
- const ctx = isDict(scene["context"]) ? scene["context"] : {};
1328
- const pluralKey = kind === "actor" ? "actors" : kind === "location" ? "locations" : "props";
1329
- for (const ref of asList(ctx[pluralKey])) {
1330
- scopeIds.add(strOf(ref[idKey]));
1331
- }
1332
- if (opts.inFilter.actionIndex !== null) {
1333
- // Narrow further to a single action's refs.
1334
- const actions = asList(scene["actions"]);
1335
- const a = actions[opts.inFilter.actionIndex];
1336
- if (!a)
1337
- continue;
1338
- scopeIds = new Set();
1339
- if (kind === "actor") {
1340
- const aid = strOf(a["actor_id"]).trim();
1341
- if (aid)
1342
- scopeIds.add(aid);
1343
- }
1344
- }
1345
- }
1346
- }
1347
- }
1348
- for (const asset of asList(script[key])) {
1349
- const id = strOf(asset[idKey]);
1350
- const name = strOf(asset[nameKey]);
1351
- if (opts.id && opts.id !== id)
1352
- continue;
1353
- if (opts.name && !name.includes(opts.name))
1354
- continue;
1355
- if (scopeIds && !scopeIds.has(id))
1356
- continue;
1357
- const aliases = asList(asset["aliases"]).map((s) => strOf(s));
1358
- const role = strOf(asset["role_type"]);
1359
- const descPresent = strOf(asset["description"]).trim() ? "yes" : "missing";
1360
- const states = asList(asset["states"]).length;
1361
- const extra = [];
1362
- if (aliases.length > 0)
1363
- extra.push(`aliases=(${aliases.join(",")})`);
1364
- if (role)
1365
- extra.push(`role=${role}`);
1366
- extra.push(`states=${states}`, `description=${descPresent}`);
1367
- lines.push(`${kind} ${id}: ${name || "-"} ${extra.join(" ")}`);
1368
- }
1369
- return lines;
1370
- }
1371
- export async function commandActors(opts) {
1372
- const session = await loadScriptForEdit(opts);
1373
- const lines = listAssetsByKind(session.script, "actor", {
1374
- id: strOf(opts["id"]).trim(),
1375
- name: strOf(opts["name"]).trim(),
1376
- inFilter: parseInFilter(strOf(opts["in"])),
1377
- });
1378
- return [buildReport("query.actors", "ACTORS", lines, session.artifactLabel, ["Use `scriptctl rename actor:<id>` / `describe` / `merge` to edit."]), EXIT_OK];
1379
- }
1380
- export async function commandLocations(opts) {
1381
- const session = await loadScriptForEdit(opts);
1382
- const lines = listAssetsByKind(session.script, "location", {
1383
- id: strOf(opts["id"]).trim(),
1384
- name: strOf(opts["name"]).trim(),
1385
- inFilter: parseInFilter(strOf(opts["in"])),
1386
- });
1387
- return [buildReport("query.locations", "LOCATIONS", lines, session.artifactLabel, ["Use `scriptctl rename location:<id>` etc. to edit."]), EXIT_OK];
1388
- }
1389
- export async function commandProps(opts) {
1390
- const session = await loadScriptForEdit(opts);
1391
- const lines = listAssetsByKind(session.script, "prop", {
1392
- id: strOf(opts["id"]).trim(),
1393
- name: strOf(opts["name"]).trim(),
1394
- inFilter: parseInFilter(strOf(opts["in"])),
1395
- });
1396
- return [buildReport("query.props", "PROPS", lines, session.artifactLabel, ["Use `scriptctl rename prop:<id>` etc. to edit."]), EXIT_OK];
1397
- }
1398
- export async function commandAssets(opts) {
1399
- const session = await loadScriptForEdit(opts);
1400
- const kindFilter = strOf(opts["kind"]).trim();
1401
- const nameOpt = strOf(opts["name"]).trim();
1402
- const idOpt = strOf(opts["id"]).trim();
1403
- const inFilter = parseInFilter(strOf(opts["in"]));
1404
- const lines = [];
1405
- for (const kind of ["actor", "location", "prop"]) {
1406
- if (kindFilter && kindFilter !== kind)
1407
- continue;
1408
- lines.push(...listAssetsByKind(session.script, kind, { id: idOpt, name: nameOpt, inFilter }));
1409
- }
1410
- return [buildReport("query.assets", "ASSETS", lines, session.artifactLabel, ["Use `scriptctl actors` / `locations` / `props` for kind-scoped views."]), EXIT_OK];
1411
- }
1412
- // ----- speakers -------------------------------------------------------------
1413
- export async function commandSpeakers(opts) {
1414
- const session = await loadScriptForEdit(opts);
1415
- const script = session.script;
1416
- const idOpt = strOf(opts["id"]).trim();
1417
- const nameOpt = strOf(opts["name"]).trim();
1418
- const kindOpt = strOf(opts["kind"]).trim();
1419
- const lines = [];
1420
- for (const sp of asList(script["speakers"])) {
1421
- const id = strOf(sp["speaker_id"]);
1422
- const name = strOf(sp["display_name"]);
1423
- const sourceKind = strOf(sp["source_kind"]);
1424
- const sourceId = strOf(sp["source_id"]);
1425
- if (idOpt && idOpt !== id)
1426
- continue;
1427
- if (nameOpt && !name.includes(nameOpt))
1428
- continue;
1429
- if (kindOpt && kindOpt !== sourceKind)
1430
- continue;
1431
- lines.push(`${id}: ${name} [${sourceKind}] source=${sourceId || "-"}`);
1432
- }
1433
- 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];
1434
- }
1435
- // ----- issues ---------------------------------------------------------------
1436
- export async function commandIssues(opts) {
1437
- const session = await loadScriptForEdit(opts);
1438
- const severityFilter = strOf(opts["severity"]).trim();
1439
- const codeFilter = strOf(opts["code"]).trim();
1440
- const validation = validateSession(session);
1441
- const lines = [];
1442
- for (const issue of asList(validation["issues"])) {
1443
- if (severityFilter && severityFilter !== strOf(issue["severity"]))
1444
- continue;
1445
- if (codeFilter && codeFilter !== strOf(issue["code"]))
1446
- continue;
1447
- const whereParts = [];
1448
- for (const k of ["episode", "scene", "action_index"]) {
1449
- if (issue[k] !== null && issue[k] !== undefined)
1450
- whereParts.push(strOf(issue[k]));
1451
- }
1452
- const where = whereParts.join(" ");
1453
- lines.push(`${issue["severity"]} ${issue["code"]}: ${issue["summary"]}${where ? ` [${where}]` : ""}`);
1454
- }
1455
- return [buildReport("query.issues", "ISSUES", lines, session.artifactLabel, ["Use the relevant edit verb to repair each issue."]), EXIT_OK];
1456
- }
1457
- // ----- refs (unified reverse lookup) ----------------------------------------
1458
- export async function commandRefs(opts) {
1459
- const session = await loadScriptForEdit(opts);
1460
- const script = session.script;
1461
- const args = asList(opts["_args"]);
1462
- const target = strOf(args[0]).trim();
1463
- if (!target) {
1464
- throw new CliError("QUERY BLOCKED: refs target missing", "refs target missing.", {
1465
- exitCode: EXIT_USAGE,
1466
- required: ["address: actor|location|prop:<id>[/<state_id>] or spk_<id>"],
1467
- received: ["<empty>"],
1468
- nextSteps: ["Pass a recognized address."],
1469
- errorCode: "REFS_TARGET_MISSING",
1470
- });
1471
- }
1472
- const level = strOf(opts["level"]).trim(); // scene|action|""
1473
- const addr = parseAnyAddress(target);
1474
- let refs = [];
1475
- if (addr.kind === "state") {
1476
- refs = collectStateRefs(script, addr.assetKind, addr.assetId, addr.stateId);
1477
- }
1478
- else if (addr.kind === "asset") {
1479
- refs = collectAssetRefs(script, addr.assetKind, addr.assetId);
1480
- }
1481
- else if (addr.kind === "speaker") {
1482
- refs = collectAssetRefs(script, "speaker", addr.speakerId);
1483
- }
1484
- else {
1485
- throw new CliError("QUERY BLOCKED: refs target invalid", "refs target invalid.", {
1486
- exitCode: EXIT_USAGE,
1487
- required: ["asset / state / speaker address"],
1488
- received: [target],
1489
- nextSteps: ["Use a kind:id, kind:id/state_id, or spk_id form."],
1490
- errorCode: "REFS_TARGET_INVALID",
1491
- });
1492
- }
1493
- const lines = [];
1494
- for (const ref of refs) {
1495
- const role = strOf(ref["role"]);
1496
- if (level === "scene" && !role.startsWith("scene_") && role !== "speaker_source")
1497
- continue;
1498
- if (level === "action" && (role.startsWith("scene_") || role === "speaker_source"))
1499
- continue;
1500
- lines.push(`${ref["location"]} [${ref["role"]}]`);
1501
- }
1502
- return [buildReport("query.refs", `REFS ${target}`, lines, session.artifactLabel, ["Use the relevant edit verb (merge / delete --strategy / rename) to mutate."]), EXIT_OK];
1503
- }
1504
- // ===========================================================================
1505
- // 0.6.0 edit verbs (flat top-level — replace `script <group> <leaf>` chains)
1506
- // ===========================================================================
1507
- //
1508
- // Each verb is a thin function over applySingleScriptOp / parseAnyAddress.
1509
- // Multipolar verbs (delete / merge / move / describe / insert) dispatch on
1510
- // the first positional address to pick the underlying dot-style patch op.
1511
- // ----- content-level (action) -----------------------------------------------
1512
- export async function commandReplace(opts) {
1513
- const args = asList(opts["_args"]);
1514
- if (args.length < 1) {
1515
- throw new CliError("VERB BLOCKED: Action ref missing", "Action ref missing.", {
1516
- exitCode: EXIT_USAGE,
1517
- required: ["action ref: ep_001/scn_001#3"],
1518
- received: ["<empty>"],
1519
- nextSteps: ["Run `scriptctl --help` for the full command surface."],
1520
- errorCode: "ACTION_REF_MISSING",
1521
- });
1522
- }
1523
- if (opts["from"] === undefined) {
1524
- throw new CliError("VERB BLOCKED: --from missing", "--from missing.", {
1525
- exitCode: EXIT_USAGE,
1526
- required: ["--from <text>"],
1527
- received: ["<missing>"],
1528
- nextSteps: ["Pass --from with the substring to replace."],
1529
- errorCode: "FROM_TEXT_EMPTY",
1530
- });
1531
- }
1532
- return applySingleScriptOp(opts, {
1533
- op: "action.content.replace",
1534
- at: args[0],
1535
- from: opts["from"],
1536
- to: opts["to"] ?? "",
1537
- all: Boolean(opts["all"]),
1538
- });
1539
- }
1540
- export async function commandType(opts) {
1541
- const args = asList(opts["_args"]);
1542
- if (args.length < 2) {
1543
- throw new CliError("VERB BLOCKED: Type args missing", "Type args missing.", {
1544
- exitCode: EXIT_USAGE,
1545
- required: ["<action ref> <type>"],
1546
- received: args,
1547
- nextSteps: ["Example: scriptctl type ep_001/scn_001#3 dialogue"],
1548
- errorCode: "ARGS_MISSING",
1549
- });
1550
- }
1551
- return applySingleScriptOp(opts, { op: "action.type.set", at: args[0], type: args[1] });
1552
- }
1553
- export async function commandActor(opts) {
1554
- const args = asList(opts["_args"]);
1555
- if (args.length < 2) {
1556
- throw new CliError("VERB BLOCKED: Actor args missing", "Actor args missing.", {
1557
- exitCode: EXIT_USAGE,
1558
- required: ["<action ref> <actor_id|none>"],
1559
- received: args,
1560
- nextSteps: ["Example: scriptctl actor ep_001/scn_001#3 act_002"],
1561
- errorCode: "ARGS_MISSING",
1562
- });
1563
- }
1564
- // Empty string is almost always a shell-expansion accident ("$ACTOR" with
1565
- // $ACTOR unset). Old set_action_actor required explicit allow_null; we
1566
- // restore the safety by demanding the literal token `none` to clear.
1567
- if (args[1].trim() === "") {
1568
- throw new CliError("VERB BLOCKED: actor_id empty", "actor_id is empty.", {
1569
- exitCode: EXIT_USAGE,
1570
- required: ["<actor_id> or the literal 'none' to clear"],
1571
- received: [`"${args[1]}"`],
1572
- nextSteps: ["Pass an existing actor id, or pass 'none' to explicitly clear."],
1573
- errorCode: "ACTOR_ID_EMPTY",
1574
- });
1575
- }
1576
- return applySingleScriptOp(opts, { op: "action.actor.set", at: args[0], actor_id: args[1] });
1577
- }
1578
- // ----- structural (multipolar delete / move / insert) -----------------------
1579
- export async function commandDelete(opts) {
1580
- const args = asList(opts["_args"]);
1581
- if (args.length < 1) {
1582
- throw new CliError("VERB BLOCKED: Address missing", "Address missing.", {
1583
- exitCode: EXIT_USAGE,
1584
- required: ["address"],
1585
- received: ["<empty>"],
1586
- nextSteps: ["Pass an address (action / scene / asset / speaker)."],
1587
- errorCode: "ADDRESS_MISSING",
1588
- });
1589
- }
1590
- const addr = parseAnyAddress(args[0]);
1591
- // Per-kind flag whitelist — silently ignoring wrong-kind flags hid real
1592
- // misuse (e.g. `delete actor:X --force` did NOT bypass ref protection, but
1593
- // the user assumed it did). Surface the mismatch with an explicit error.
1594
- const flagsUsed = {
1595
- strategy: opts["strategy"] !== undefined,
1596
- replacement: opts["replacement"] !== undefined,
1597
- force: Boolean(opts["force"]),
1598
- };
1599
- function rejectFlags(kind, allowed) {
1600
- const allow = new Set(allowed);
1601
- const bad = [];
1602
- for (const f of ["strategy", "replacement", "force"]) {
1603
- if (flagsUsed[f] && !allow.has(f))
1604
- bad.push(`--${f}`);
1605
- }
1606
- if (bad.length > 0) {
1607
- throw new CliError("VERB BLOCKED: flag not applicable", `${bad.join(", ")} cannot be used with ${kind} delete.`, {
1608
- exitCode: EXIT_USAGE,
1609
- required: [`for ${kind} delete: ${allowed.length === 0 ? "no extra flags" : allowed.map((f) => `--${f}`).join(" / ")}`],
1610
- received: bad,
1611
- nextSteps: ["Drop the unsupported flag(s) and retry."],
1612
- errorCode: "DELETE_FLAG_INVALID",
1613
- });
1614
- }
1615
- }
1616
- const op = (() => {
1617
- if (addr.kind === "action") {
1618
- rejectFlags("action", []);
1619
- return { op: "action.delete", at: args[0] };
1620
- }
1621
- if (addr.kind === "scene") {
1622
- rejectFlags("scene", ["force"]);
1623
- return { op: "scene.delete", at: args[0], force: Boolean(opts["force"]) };
1624
- }
1625
- if (addr.kind === "asset") {
1626
- rejectFlags("asset", ["strategy", "replacement"]);
1627
- return {
1628
- op: "asset.delete",
1629
- target: args[0],
1630
- strategy: opts["strategy"],
1631
- replacement: opts["replacement"],
1632
- };
1633
- }
1634
- if (addr.kind === "speaker") {
1635
- rejectFlags("speaker", ["strategy", "replacement"]);
1636
- return {
1637
- op: "speaker.delete",
1638
- target: addr.speakerId,
1639
- strategy: opts["strategy"],
1640
- replacement: opts["replacement"],
1641
- };
1642
- }
1643
- throw new CliError("VERB BLOCKED: delete cannot operate on this address", "delete cannot operate on this address.", {
1644
- exitCode: EXIT_USAGE,
1645
- required: ["action / scene / asset / speaker address"],
1646
- received: [args[0]],
1647
- nextSteps: ["Use a supported address."],
1648
- errorCode: "ADDRESS_UNSUPPORTED",
1649
- });
1650
- })();
1651
- return applySingleScriptOp(opts, op);
1652
- }
1653
- export async function commandMove(opts) {
1654
- const args = asList(opts["_args"]);
1655
- if (args.length < 2) {
1656
- throw new CliError("VERB BLOCKED: Move args missing", "Move args missing.", {
1657
- exitCode: EXIT_USAGE,
1658
- required: ["<from-addr> <to-addr>"],
1659
- received: args,
1660
- nextSteps: ["Example: scriptctl move ep_001/scn_001#5 ep_001/scn_002#0"],
1661
- errorCode: "ARGS_MISSING",
1662
- });
1663
- }
1664
- const addr = parseAnyAddress(args[0]);
1665
- if (addr.kind === "action") {
1666
- return applySingleScriptOp(opts, { op: "action.move", at: args[0], to: args[1] });
1667
- }
1668
- if (addr.kind === "scene") {
1669
- return applySingleScriptOp(opts, {
1670
- op: "scene.move", at: args[0], to: args[1], at_index: opts["at_index"] ?? opts["at"],
1671
- });
1672
- }
1673
- throw new CliError("VERB BLOCKED: move cannot operate on this address", "move cannot operate on this address.", {
1674
- exitCode: EXIT_USAGE,
1675
- required: ["action / scene address"],
1676
- received: [args[0]],
1677
- nextSteps: ["Use an action or scene address."],
1678
- errorCode: "ADDRESS_UNSUPPORTED",
1679
- });
1680
- }
1681
- export async function commandInsert(opts) {
1682
- const args = asList(opts["_args"]);
1683
- if (args.length < 1) {
1684
- throw new CliError("VERB BLOCKED: Insert address missing", "Insert address missing.", {
1685
- exitCode: EXIT_USAGE,
1686
- required: ["<addr>: ep_NNN/scn_NNN (action insert) or ep_NNN (scene insert)"],
1687
- received: ["<empty>"],
1688
- nextSteps: ["Example: scriptctl insert ep_001/scn_001 --type action --content \"...\""],
1689
- errorCode: "ADDRESS_MISSING",
1690
- });
1691
- }
1692
- const addr = parseAnyAddress(args[0]);
1693
- // Per-kind flag whitelist. Silently dropping mismatched flags hid two
1694
- // real bugs:
1695
- // - scene insert was ignoring --scene-id (the user lost custom IDs).
1696
- // - mixing action + scene insert flags (`--type` with episode addr, or
1697
- // `--location` with scene addr) ran the wrong dispatch silently.
1698
- const flagsUsed = {
1699
- type: opts["type"] !== undefined,
1700
- content: opts["content"] !== undefined,
1701
- actor: (opts["actor_id"] ?? opts["actor"]) !== undefined,
1702
- speaker: (opts["speaker_id"] ?? opts["speaker"]) !== undefined,
1703
- location: opts["location"] !== undefined,
1704
- time: opts["time"] !== undefined,
1705
- space: opts["space"] !== undefined,
1706
- "scene-id": opts["scene_id"] !== undefined,
1707
- };
1708
- function rejectFlags(kind, allowed) {
1709
- const allow = new Set(allowed);
1710
- const bad = [];
1711
- for (const f of Object.keys(flagsUsed)) {
1712
- if (flagsUsed[f] && !allow.has(f))
1713
- bad.push(`--${f}`);
1714
- }
1715
- if (bad.length > 0) {
1716
- throw new CliError("VERB BLOCKED: flag not applicable", `${bad.join(", ")} cannot be used with ${kind} insert.`, {
1717
- exitCode: EXIT_USAGE,
1718
- required: [`for ${kind} insert: ${allowed.map((f) => `--${f}`).join(" / ")}`],
1719
- received: bad,
1720
- nextSteps: ["Drop the unsupported flag(s) and retry."],
1721
- errorCode: "INSERT_FLAG_INVALID",
1722
- });
1723
- }
1724
- }
1725
- if (addr.kind === "scene") {
1726
- rejectFlags("action", ["type", "content", "actor", "speaker"]);
1727
- return applySingleScriptOp(opts, {
1728
- op: "action.insert",
1729
- at: args[0],
1730
- type: opts["type"],
1731
- content: opts["content"],
1732
- at_index: opts["at_index"] ?? opts["at"],
1733
- before: opts["before"],
1734
- after: opts["after"],
1735
- actor_id: opts["actor_id"] ?? opts["actor"],
1736
- speaker_id: opts["speaker_id"] ?? opts["speaker"],
1737
- });
1738
- }
1739
- if (addr.kind === "episode") {
1740
- rejectFlags("scene", ["location", "time", "space", "scene-id"]);
1741
- return applySingleScriptOp(opts, {
1742
- op: "scene.insert",
1743
- at: addr.episodeId,
1744
- location: opts["location"],
1745
- time: opts["time"],
1746
- space: opts["space"],
1747
- // Forward --scene-id (was previously silently dropped — scene.insert op
1748
- // reads `scene_id` to honor user-supplied ids for round-trip stability).
1749
- scene_id: opts["scene_id"],
1750
- at_index: opts["at_index"] ?? opts["at"],
1751
- before: opts["before"],
1752
- after: opts["after"],
1753
- });
1754
- }
1755
- throw new CliError("VERB BLOCKED: insert cannot operate on this address", "insert cannot operate on this address.", {
1756
- exitCode: EXIT_USAGE,
1757
- required: ["ep_NNN (scene insert) or ep_NNN/scn_NNN (action insert)"],
1758
- received: [args[0]],
1759
- nextSteps: ["Use an episode or scene address."],
1760
- errorCode: "ADDRESS_UNSUPPORTED",
1761
- });
1762
- }
1763
- export async function commandSplit(opts) {
1764
- const args = asList(opts["_args"]);
1765
- if (args.length < 1) {
1766
- throw new CliError("VERB BLOCKED: Scene ref missing", "Scene ref missing.", {
1767
- exitCode: EXIT_USAGE,
1768
- required: ["<ep/scn>"],
1769
- received: ["<empty>"],
1770
- nextSteps: ["Example: scriptctl split ep_001/scn_005 --at 7"],
1771
- errorCode: "ARGS_MISSING",
1772
- });
1773
- }
1774
- // Commander hands back --at as a string; the scene.split handler does a
1775
- // strict typeof === "number" check (no implicit coercion), so we coerce here
1776
- // before dispatch. Without this, every CLI `scriptctl split` invocation
1777
- // would fail with SPLIT_INDEX_INVALID.
1778
- const rawAt = opts["at_index"] ?? opts["at"];
1779
- const atIndex = rawAt === undefined || rawAt === null || rawAt === "" ? undefined : Number(rawAt);
1780
- if (atIndex !== undefined && (!Number.isFinite(atIndex) || !Number.isInteger(atIndex))) {
1781
- throw new CliError("VERB BLOCKED: --at not an integer", "--at must be an integer.", {
1782
- exitCode: EXIT_USAGE,
1783
- required: ["--at <integer>"],
1784
- received: [String(rawAt)],
1785
- nextSteps: ["Pass an integer action index."],
1786
- errorCode: "AT_INDEX_INVALID",
1787
- });
1788
- }
1789
- return applySingleScriptOp(opts, {
1790
- op: "scene.split",
1791
- at: args[0],
1792
- at_index: atIndex,
1793
- new_scene_id: opts["new_scene_id"],
1794
- });
1795
- }
1796
- export async function commandMerge(opts) {
1797
- const args = asList(opts["_args"]);
1798
- if (args.length < 1) {
1799
- throw new CliError("VERB BLOCKED: Source address missing", "Source address missing.", {
1800
- exitCode: EXIT_USAGE,
1801
- required: ["<src-addr> --into <dst-addr>"],
1802
- received: args,
1803
- nextSteps: ["Example: scriptctl merge actor:act_001 --into actor:act_002"],
1804
- errorCode: "ARGS_MISSING",
1805
- });
1806
- }
1807
- const into = strOf(opts["into"]);
1808
- if (!into) {
1809
- throw new CliError("VERB BLOCKED: --into missing", "--into missing.", {
1810
- exitCode: EXIT_USAGE,
1811
- required: ["--into <dst-addr>"],
1812
- received: ["<missing>"],
1813
- nextSteps: ["Pass --into <addr>."],
1814
- errorCode: "INTO_MISSING",
1815
- });
1816
- }
1817
- const fromAddr = parseAnyAddress(args[0]);
1818
- const intoAddr = parseAnyAddress(into);
1819
- if (fromAddr.kind === "asset" && intoAddr.kind === "asset") {
1820
- return applySingleScriptOp(opts, { op: "asset.merge", from: args[0], into });
1821
- }
1822
- if (fromAddr.kind === "scene" && intoAddr.kind === "scene") {
1823
- return applySingleScriptOp(opts, { op: "scene.merge", from: args[0], into });
1824
- }
1825
- throw new CliError("VERB BLOCKED: merge address mismatch", "merge: both sides must be the same kind (asset or scene).", {
1826
- exitCode: EXIT_USAGE,
1827
- required: ["asset & asset, or scene & scene"],
1828
- received: [`${args[0]} → ${into}`],
1829
- nextSteps: ["Use two addresses of the same kind."],
1830
- errorCode: "MERGE_ADDR_MISMATCH",
1831
- });
1832
- }
1833
- // ----- asset metadata -------------------------------------------------------
1834
- export async function commandRename(opts) {
1835
- const args = asList(opts["_args"]);
1836
- if (args.length < 2) {
1837
- throw new CliError("VERB BLOCKED: Rename args missing", "Rename args missing.", {
1838
- exitCode: EXIT_USAGE,
1839
- required: ["<asset-addr> <new-name>"],
1840
- received: args,
1841
- nextSteps: ["Example: scriptctl rename actor:act_001 \"陈墨\""],
1842
- errorCode: "ARGS_MISSING",
1843
- });
1844
- }
1845
- return applySingleScriptOp(opts, { op: "asset.rename", target: args[0], name: args[1] });
1846
- }
1847
- export async function commandDescribe(opts) {
1848
- const args = asList(opts["_args"]);
1849
- if (args.length < 2) {
1850
- throw new CliError("VERB BLOCKED: Describe args missing", "Describe args missing.", {
1851
- exitCode: EXIT_USAGE,
1852
- required: ["<addr> <text>"],
1853
- received: args,
1854
- nextSteps: ["Example: scriptctl describe actor:act_001 \"男主\" or scriptctl describe actor:act_001/st_calm \"...\""],
1855
- errorCode: "ARGS_MISSING",
1856
- });
1857
- }
1858
- const addr = parseAnyAddress(args[0]);
1859
- if (addr.kind === "state") {
1860
- return applySingleScriptOp(opts, { op: "state.describe", target: args[0], description: args[1] });
1861
- }
1862
- if (addr.kind === "asset") {
1863
- return applySingleScriptOp(opts, { op: "asset.describe", target: args[0], description: args[1] });
1864
- }
1865
- throw new CliError("VERB BLOCKED: describe address invalid", "describe address invalid.", {
1866
- exitCode: EXIT_USAGE,
1867
- required: ["asset address (e.g. actor:act_001) or state address (e.g. actor:act_001/st_calm)"],
1868
- received: [args[0]],
1869
- nextSteps: ["Use a supported address."],
1870
- errorCode: "ADDRESS_UNSUPPORTED",
1871
- });
1872
- }
1873
- export async function commandAlias(opts) {
1874
- const args = asList(opts["_args"]);
1875
- if (args.length < 1) {
1876
- throw new CliError("VERB BLOCKED: Asset address missing", "Asset address missing.", {
1877
- exitCode: EXIT_USAGE,
1878
- required: ["<asset-addr>"],
1879
- received: ["<empty>"],
1880
- nextSteps: ["Example: scriptctl alias actor:act_001 --add \"陈总\""],
1881
- errorCode: "ARGS_MISSING",
1882
- });
1883
- }
1884
- // appendStr collectors default to []; empty arrays are truthy, so we MUST
1885
- // check `.length === 0` rather than `!add` (which is always false here).
1886
- // Without this, passing only --add still runs the remove op with alias=[]
1887
- // and crashes mid-write at op layer after the add already succeeded.
1888
- const add = asList(opts["add"]);
1889
- const remove = asList(opts["remove"]);
1890
- if (add.length === 0 && remove.length === 0) {
1891
- throw new CliError("VERB BLOCKED: --add or --remove required", "--add or --remove required.", {
1892
- exitCode: EXIT_USAGE,
1893
- required: ["--add <alias> and/or --remove <alias>"],
1894
- received: ["<empty>"],
1895
- nextSteps: ["Pass --add and/or --remove at least once."],
1896
- errorCode: "ALIAS_EMPTY",
1897
- });
1898
- }
1899
- // Run only the ops that have content; either one of them solo, or both.
1900
- let lastReport = null;
1901
- if (add.length > 0) {
1902
- lastReport = await applySingleScriptOp(opts, { op: "asset.alias.add", target: args[0], alias: add });
1903
- }
1904
- if (remove.length > 0) {
1905
- lastReport = await applySingleScriptOp(opts, { op: "asset.alias.remove", target: args[0], alias: remove });
1906
- }
1907
- return lastReport;
1908
- }
1909
- export async function commandRole(opts) {
1910
- const args = asList(opts["_args"]);
1911
- if (args.length < 2) {
1912
- throw new CliError("VERB BLOCKED: Role args missing", "Role args missing.", {
1913
- exitCode: EXIT_USAGE,
1914
- required: ["<actor:id> <主角|配角>"],
1915
- received: args,
1916
- nextSteps: ["Example: scriptctl role actor:act_001 主角"],
1917
- errorCode: "ARGS_MISSING",
1918
- });
1919
- }
1920
- return applySingleScriptOp(opts, { op: "asset.role.set", target: args[0], role_type: args[1] });
1921
- }
1922
- export async function commandWorldview(opts) {
1923
- const args = asList(opts["_args"]);
1924
- if (args.length < 1) {
1925
- throw new CliError("VERB BLOCKED: Worldview value missing", "Worldview value missing.", {
1926
- exitCode: EXIT_USAGE,
1927
- required: ["<value>"],
1928
- received: ["<empty>"],
1929
- nextSteps: ["Example: scriptctl worldview 现代"],
1930
- errorCode: "ARGS_MISSING",
1931
- });
1932
- }
1933
- return applySingleScriptOp(opts, { op: "meta.worldview.set", worldview: args[0] });
1934
- }
1935
- // ----- state management -----------------------------------------------------
1936
- export async function commandStateAdd(opts) {
1937
- const args = asList(opts["_args"]);
1938
- if (args.length < 2) {
1939
- throw new CliError("VERB BLOCKED: state-add args missing", "state-add args missing.", {
1940
- exitCode: EXIT_USAGE,
1941
- required: ["<asset-addr> <state-name>"],
1942
- received: args,
1943
- nextSteps: ["Example: scriptctl state-add actor:act_001 \"震惊\""],
1944
- errorCode: "ARGS_MISSING",
1945
- });
1946
- }
1947
- return applySingleScriptOp(opts, {
1948
- op: "state.add",
1949
- target: args[0],
1950
- name: args[1],
1951
- description: opts["description"],
1952
- state_id: opts["state_id"],
1953
- });
1954
- }
1955
- export async function commandStateRename(opts) {
1956
- const args = asList(opts["_args"]);
1957
- if (args.length < 2) {
1958
- throw new CliError("VERB BLOCKED: state-rename args missing", "state-rename args missing.", {
1959
- exitCode: EXIT_USAGE,
1960
- required: ["<state-addr> <new-name>"],
1961
- received: args,
1962
- nextSteps: ["Example: scriptctl state-rename actor:act_001/st_calm \"平静(新)\""],
1963
- errorCode: "ARGS_MISSING",
1964
- });
1965
- }
1966
- return applySingleScriptOp(opts, { op: "state.rename", target: args[0], name: args[1] });
1967
- }
1968
- export async function commandStateDelete(opts) {
1969
- const args = asList(opts["_args"]);
1970
- if (args.length < 1) {
1971
- throw new CliError("VERB BLOCKED: state-delete address missing", "state-delete address missing.", {
1972
- exitCode: EXIT_USAGE,
1973
- required: ["<state-addr>"],
1974
- received: ["<empty>"],
1975
- nextSteps: ["Example: scriptctl state-delete actor:act_001/st_calm --strategy remove"],
1976
- errorCode: "ARGS_MISSING",
1977
- });
1978
- }
1979
- return applySingleScriptOp(opts, {
1980
- op: "state.delete",
1981
- target: args[0],
1982
- strategy: opts["strategy"],
1983
- replacement: opts["replacement"],
1984
- });
1985
- }
1986
- // ----- scene context ref ----------------------------------------------------
1987
- export async function commandContext(opts) {
1988
- const args = asList(opts["_args"]);
1989
- if (args.length < 2) {
1990
- throw new CliError("VERB BLOCKED: context args missing", "context args missing.", {
1991
- exitCode: EXIT_USAGE,
1992
- required: ["<scene-addr> <asset-addr>"],
1993
- received: args,
1994
- nextSteps: ["Example: scriptctl context ep_001/scn_001 actor:act_001 --state st_calm"],
1995
- errorCode: "ARGS_MISSING",
1996
- });
1997
- }
1998
- // Mutex XOR: help-text promises 'exactly one of --state / --clear /
1999
- // --remove'. Enforce it instead of silently picking by precedence.
2000
- const stateSet = opts["state"] !== undefined;
2001
- const clearSet = Boolean(opts["clear"]);
2002
- const removeSet = Boolean(opts["remove"]);
2003
- const flagCount = (stateSet ? 1 : 0) + (clearSet ? 1 : 0) + (removeSet ? 1 : 0);
2004
- if (flagCount === 0) {
2005
- throw new CliError("VERB BLOCKED: context flag missing", "context: must specify --state X, --clear, or --remove.", {
2006
- exitCode: EXIT_USAGE,
2007
- required: ["one of --state <state_id> / --clear / --remove"],
2008
- received: ["<missing>"],
2009
- nextSteps: ["Pick exactly one flag."],
2010
- errorCode: "CONTEXT_FLAG_MISSING",
2011
- });
2012
- }
2013
- if (flagCount > 1) {
2014
- const passed = [];
2015
- if (stateSet)
2016
- passed.push("--state");
2017
- if (clearSet)
2018
- passed.push("--clear");
2019
- if (removeSet)
2020
- passed.push("--remove");
2021
- throw new CliError("VERB BLOCKED: context flags mutually exclusive", "context: --state / --clear / --remove are mutually exclusive.", {
2022
- exitCode: EXIT_USAGE,
2023
- required: ["exactly one of --state / --clear / --remove"],
2024
- received: passed,
2025
- nextSteps: ["Pick one and retry."],
2026
- errorCode: "CONTEXT_FLAG_CONFLICT",
2027
- });
2028
- }
2029
- if (removeSet) {
2030
- return applySingleScriptOp(opts, { op: "context.ref.remove", at: args[0], target: args[1] });
2031
- }
2032
- if (clearSet) {
2033
- return applySingleScriptOp(opts, { op: "context.clear", at: args[0], target: args[1] });
2034
- }
2035
- const state = opts["state"];
2036
- return applySingleScriptOp(opts, {
2037
- op: "context.set",
2038
- at: args[0],
2039
- target: args[1],
2040
- state: state === "none" ? "" : state,
2041
- });
2042
- }
2043
- // ----- state change / transition --------------------------------------------
2044
- export async function commandStateChange(opts) {
2045
- const args = asList(opts["_args"]);
2046
- if (args.length < 2) {
2047
- throw new CliError("VERB BLOCKED: state-change args missing", "state-change args missing.", {
2048
- exitCode: EXIT_USAGE,
2049
- required: ["<action-addr> <asset-addr>"],
2050
- received: args,
2051
- nextSteps: ["Example: scriptctl state-change ep_001/scn_001#3 actor:act_001 --to st_shock"],
2052
- errorCode: "ARGS_MISSING",
2053
- });
2054
- }
2055
- // Mutex XOR: --to (set) and --clear (remove) cannot coexist.
2056
- const toSet = opts["to"] !== undefined;
2057
- const clearSet = Boolean(opts["clear"]);
2058
- if (toSet && clearSet) {
2059
- throw new CliError("VERB BLOCKED: state-change flags mutually exclusive", "state-change: --to and --clear are mutually exclusive.", {
2060
- exitCode: EXIT_USAGE,
2061
- required: ["exactly one of --to <state> / --clear"],
2062
- received: ["--to", "--clear"],
2063
- nextSteps: ["Pick one and retry."],
2064
- errorCode: "STATE_CHANGE_FLAG_CONFLICT",
2065
- });
2066
- }
2067
- if (!toSet && !clearSet) {
2068
- throw new CliError("VERB BLOCKED: state-change flag missing", "state-change: must specify --to <state> or --clear.", {
2069
- exitCode: EXIT_USAGE,
2070
- required: ["one of --to <state> / --clear"],
2071
- received: ["<missing>"],
2072
- nextSteps: ["Pick exactly one flag."],
2073
- errorCode: "STATE_CHANGE_FLAG_MISSING",
2074
- });
2075
- }
2076
- if (clearSet) {
2077
- return applySingleScriptOp(opts, { op: "action.state.remove", at: args[0], target: args[1] });
2078
- }
2079
- return applySingleScriptOp(opts, {
2080
- op: "action.state.change",
2081
- at: args[0],
2082
- target: args[1],
2083
- to: opts["to"],
2084
- from: opts["from"],
2085
- effective: opts["effective"] || "after",
2086
- });
2087
- }
2088
- export async function commandTransition(opts) {
2089
- const args = asList(opts["_args"]);
2090
- if (args.length < 2) {
2091
- throw new CliError("VERB BLOCKED: transition args missing", "transition args missing.", {
2092
- exitCode: EXIT_USAGE,
2093
- required: ["<action-addr> <asset-addr>"],
2094
- received: args,
2095
- nextSteps: ["Example: scriptctl transition ep_001/scn_001#3 actor:act_001 --process \"...\" --contrast \"...\""],
2096
- errorCode: "ARGS_MISSING",
2097
- });
2098
- }
2099
- // Mutex XOR: --clear vs --process/--contrast.
2100
- const setBoth = opts["process"] !== undefined || opts["contrast"] !== undefined;
2101
- const clearSet = Boolean(opts["clear"]);
2102
- if (setBoth && clearSet) {
2103
- throw new CliError("VERB BLOCKED: transition flags mutually exclusive", "transition: --clear is mutually exclusive with --process/--contrast.", {
2104
- exitCode: EXIT_USAGE,
2105
- required: ["either --clear, or --process + --contrast"],
2106
- received: ["--clear", "--process/--contrast"],
2107
- nextSteps: ["Pick one mode and retry."],
2108
- errorCode: "TRANSITION_FLAG_CONFLICT",
2109
- });
2110
- }
2111
- if (!setBoth && !clearSet) {
2112
- throw new CliError("VERB BLOCKED: transition flag missing", "transition: must specify --process/--contrast or --clear.", {
2113
- exitCode: EXIT_USAGE,
2114
- required: ["--process + --contrast, or --clear"],
2115
- received: ["<missing>"],
2116
- nextSteps: ["Pick exactly one mode."],
2117
- errorCode: "TRANSITION_FLAG_MISSING",
2118
- });
2119
- }
2120
- if (clearSet) {
2121
- return applySingleScriptOp(opts, { op: "action.transition.clear", at: args[0], target: args[1] });
2122
- }
2123
- return applySingleScriptOp(opts, {
2124
- op: "action.transition.set",
2125
- at: args[0],
2126
- target: args[1],
2127
- process: opts["process"],
2128
- contrast: opts["contrast"],
2129
- });
2130
- }
2131
- // ----- dialogue / speaker ---------------------------------------------------
2132
- function splitSpeakerList(raw) {
2133
- if (Array.isArray(raw))
2134
- return raw.map((v) => strOf(v)).filter((v) => v);
2135
- return strOf(raw).split(",").map((s) => s.trim()).filter((s) => s);
2136
- }
2137
- export async function commandDialogue(opts) {
2138
- const args = asList(opts["_args"]);
2139
- if (args.length < 1) {
2140
- throw new CliError("VERB BLOCKED: dialogue action ref missing", "dialogue action ref missing.", {
2141
- exitCode: EXIT_USAGE,
2142
- required: ["<action-addr>"],
2143
- received: ["<empty>"],
2144
- nextSteps: ["Example: scriptctl dialogue ep_001/scn_001#3 --speakers spk_001"],
2145
- errorCode: "ARGS_MISSING",
2146
- });
2147
- }
2148
- const speakers = splitSpeakerList(opts["speakers"]);
2149
- if (speakers.length === 0) {
2150
- throw new CliError("VERB BLOCKED: --speakers required", "--speakers required.", {
2151
- exitCode: EXIT_USAGE,
2152
- required: ["--speakers id1[,id2,...]"],
2153
- received: ["<empty>"],
2154
- nextSteps: ["Pass one or more speaker ids."],
2155
- errorCode: "SPEAKERS_EMPTY",
2156
- });
2157
- }
2158
- return applySingleScriptOp(opts, {
2159
- op: "dialogue.speakers",
2160
- at: args[0],
2161
- speakers,
2162
- delivery: opts["delivery"] || (speakers.length > 1 ? "simultaneous" : "single"),
2163
- });
2164
- }
2165
- export async function commandOverlap(opts) {
2166
- const args = asList(opts["_args"]);
2167
- if (args.length < 1) {
2168
- throw new CliError("VERB BLOCKED: overlap action ref missing", "overlap action ref missing.", {
2169
- exitCode: EXIT_USAGE,
2170
- required: ["<action-addr>"],
2171
- received: ["<empty>"],
2172
- nextSteps: ["Example: scriptctl overlap ep_001/scn_001#3 --line \"spk_a:hi\" --line \"spk_b:hi\""],
2173
- errorCode: "ARGS_MISSING",
2174
- });
2175
- }
2176
- return applySingleScriptOp(opts, {
2177
- op: "dialogue.overlap",
2178
- at: args[0],
2179
- lines: opts["line"] || [],
2180
- });
2181
- }
2182
- export async function commandAddSpeaker(opts) {
2183
- return applySingleScriptOp(opts, {
2184
- op: "speaker.add",
2185
- kind: opts["kind"],
2186
- name: opts["name"],
2187
- source_id: opts["source_id"],
2188
- voice_desc: opts["voice_desc"],
2189
- speaker_id: opts["speaker_id"],
2190
- });
2191
- }
2192
- // ----- create (blank scaffold) ----------------------------------------------
2193
- // Create an empty schema-v2 script as the starting point for building a script
2194
- // bottom-up with the atomic add/insert verbs (instead of direct/parse/write
2195
- // assembly). Targets the same three destinations as the edit verbs:
2196
- // --script-path <file> local JSON file
2197
- // --draft the direct intermediate (script.initial.json)
2198
- // --project-group-no DB-backed script-output (first write at revision 0)
2199
- // Refuses to clobber an existing target (local: pass --force to overwrite; DB:
2200
- // edit the existing script instead). Validates in incomplete-tolerant mode, so
2201
- // the blank scaffold reports passed.
2202
- export async function commandCreate(opts) {
2203
- const workspace = strOf(opts["workspace_path"] || "workspace");
2204
- const force = Boolean(opts["force"]);
2205
- const title = strOf(opts["title"]).trim();
2206
- const script = blankScript(title);
2207
- const draft = Boolean(opts["draft"]);
2208
- const scriptPathOpt = strOf(opts["script_path"]).trim();
2209
- if (draft && scriptPathOpt) {
2210
- throw new CliError("CREATE BLOCKED: --draft conflicts with --script-path", "--draft conflicts with --script-path.", {
2211
- exitCode: EXIT_USAGE,
2212
- required: ["either --draft or --script-path, not both"],
2213
- received: ["--draft and --script-path"],
2214
- nextSteps: ["Drop one of the two flags and retry."],
2215
- errorCode: "DRAFT_FLAG_CONFLICT",
2216
- });
2217
- }
2218
- if (draft && strOf(opts["project_group_no"]).trim()) {
2219
- throw new CliError("CREATE BLOCKED: --draft conflicts with --project-group-no", "--draft conflicts with --project-group-no.", {
2220
- exitCode: EXIT_USAGE,
2221
- required: ["either --draft (local draft) or --project-group-no (DB), not both"],
2222
- received: ["--draft and --project-group-no"],
2223
- nextSteps: ["Drop one of the two flags and retry."],
2224
- errorCode: "DRAFT_FLAG_CONFLICT",
2225
- });
2226
- }
2227
- const localPath = draft ? path.join(directDir(workspace), "script.initial.json") : (scriptPathOpt || null);
2228
- if (localPath) {
2229
- if (exists(localPath) && !force) {
2230
- throw new CliError("CREATE BLOCKED: target already exists", "A script already exists at the target.", {
2231
- exitCode: EXIT_INPUT,
2232
- required: ["a non-existent target, or --force to overwrite"],
2233
- received: [localPath],
2234
- nextSteps: ["Edit the existing script with the atomic verbs, or pass --force to overwrite with a blank scaffold."],
2235
- errorCode: "SCRIPT_EXISTS",
2236
- });
2237
- }
2238
- writeJson(localPath, script);
2239
- const session = new ScriptEditSession({ workspace, scriptPath: localPath, script });
2240
- const validation = validateSession(session);
2241
- const passed = Boolean(validation["passed"]);
2242
- return [blankCreateReport(session, validation, null), passed ? EXIT_OK : EXIT_NEEDS_AGENT];
2243
- }
2244
- // DB mode: first write must be at revision 0. We never silently overwrite a
2245
- // populated DB script with a blank one — that would destroy work.
2246
- const client = scriptOutputClient(opts);
2247
- const baseRevision = await currentRevisionOrZero(client);
2248
- if (baseRevision > 0) {
2249
- throw new CliError("CREATE BLOCKED: DB script already exists", "A script already exists for this project.", {
2250
- exitCode: EXIT_INPUT,
2251
- required: ["an empty project (no published script)"],
2252
- received: [`revision=${baseRevision}`],
2253
- nextSteps: ["Edit the existing script with the atomic verbs instead of creating a blank one."],
2254
- errorCode: "SCRIPT_EXISTS",
2255
- });
2256
- }
2257
- const requestId = requestIdForScriptWrite(opts, "script.create", { script });
2258
- let res;
2259
- try {
2260
- res = await client.replaceScript({ requestId, baseRevision, script, source: "ctl" });
2261
- }
2262
- catch (exc) {
2263
- if (exc instanceof ScriptOutputApiError) {
2264
- throw apiErrorToCli("SCRIPT API BLOCKED: Create write failed", exc);
2265
- }
2266
- throw exc;
2267
- }
2268
- const revision = Number(res["revision"] ?? 0);
2269
- const session = new ScriptEditSession({ workspace, script, client, projectGroupNo: client.projectGroupNo, revision });
2270
- const validation = validateSession(session);
2271
- await syncValidationResult(session, validation, revision);
2272
- const passed = Boolean(validation["passed"]);
2273
- return [blankCreateReport(session, validation, Boolean(res["idempotent"])), passed ? EXIT_OK : EXIT_NEEDS_AGENT];
2274
- }
2275
- function blankCreateReport(session, validation, idempotent) {
2276
- const passed = Boolean(validation["passed"]);
2277
- const resultLines = ["created: blank script (schema v2)", `validation: ${passed ? "passed" : "needs repair"}`];
2278
- if (session.remote) {
2279
- resultLines.push(`revision: ${session.revision}`);
2280
- if (idempotent !== null)
2281
- resultLines.push(`idempotent: ${String(idempotent).toLowerCase()}`);
2282
- }
2283
- return {
2284
- title: passed ? "SCRIPT CREATED: Blank scaffold" : "SCRIPT CREATED: Validation needs repair",
2285
- op: "script.create",
2286
- changed: true,
2287
- summary: "已创建空白剧本(schema v2,待填充人物/地点/道具/分集)",
2288
- result: resultLines,
2289
- warnings: summarizeIssues(asList(validation["issues"])),
2290
- artifacts: [session.artifactLabel],
2291
- next: [
2292
- "用 worldview 设定世界观,用 add-actor / add-location / add-prop 注册资产,用 add-episode + insert 搭出分集与场景。",
2293
- ],
2294
- };
2295
- }
2296
- // ----- add-* (register new entities) ----------------------------------------
2297
- // Read the single positional <name> arg shared by add-actor/location/prop, or
2298
- // throw the common ARGS_MISSING usage error with a verb-specific example.
2299
- function requireNameArg(opts, verb, example) {
2300
- const args = asList(opts["_args"]);
2301
- if (args.length < 1) {
2302
- throw new CliError(`VERB BLOCKED: ${verb} name missing`, "Name missing.", {
2303
- exitCode: EXIT_USAGE,
2304
- required: ["<name>"],
2305
- received: ["<empty>"],
2306
- nextSteps: [example],
2307
- errorCode: "ARGS_MISSING",
2308
- });
2309
- }
2310
- return args[0];
2311
- }
2312
- export async function commandAddActor(opts) {
2313
- const name = requireNameArg(opts, "add-actor", "Example: scriptctl add-actor \"林夏\" --role 主角");
2314
- return applySingleScriptOp(opts, {
2315
- op: "actor.add",
2316
- name,
2317
- actor_id: opts["id"],
2318
- role_type: opts["role"],
2319
- description: opts["description"],
2320
- aliases: opts["alias"],
2321
- });
2322
- }
2323
- export async function commandAddLocation(opts) {
2324
- const name = requireNameArg(opts, "add-location", "Example: scriptctl add-location \"教室\"");
2325
- return applySingleScriptOp(opts, {
2326
- op: "location.add",
2327
- name,
2328
- location_id: opts["id"],
2329
- description: opts["description"],
2330
- });
2331
- }
2332
- export async function commandAddProp(opts) {
2333
- const name = requireNameArg(opts, "add-prop", "Example: scriptctl add-prop \"怀表\"");
2334
- return applySingleScriptOp(opts, {
2335
- op: "prop.add",
2336
- name,
2337
- prop_id: opts["id"],
2338
- description: opts["description"],
2339
- });
2340
- }
2341
- export async function commandAddEpisode(opts) {
2342
- return applySingleScriptOp(opts, {
2343
- op: "episode.add",
2344
- episode_id: opts["id"],
2345
- title: opts["title"],
2346
- });
2347
- }
2348
- //# sourceMappingURL=script.js.map