@kheleshikhi/studio-agent 0.8.0 → 0.9.1

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.
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from "node:crypto";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
4
  import { realpathSync, watch as watchFile } from "node:fs";
5
5
  import { createRequire } from "node:module";
6
- import { access, appendFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
6
+ import { access, appendFile, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
7
7
  import os from "node:os";
8
8
  import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
@@ -13,18 +13,24 @@ import { attemptDirectory, createAttemptPointer, finalizeClient, readAttemptPoin
13
13
  import { runDaemon } from "./daemon.js";
14
14
  import { audioUploadExtension, isLibraryKind, LIBRARY_STUDIO_WORKSPACE, libraryLockAfterSave, LibraryWorkspace, } from "./library.js";
15
15
  import { daemonRpc, setCliTrace } from "./protocol.js";
16
- import { cleanStaleDaemonMetadata, readConfig, readDaemonMetadata, stateDirectory, validateOrigin, writeConfig, } from "./state.js";
16
+ import { runRenderJob } from "./render-jobs.js";
17
+ import { compileDependencies, experienceProvenance } from "./shot-provenance.js";
18
+ import { digestOf } from "./shot-provenance.js";
19
+ import { episodeCues, episodeTimeline, parseEpisode } from "./episode.js";
20
+ import { planEpisode, renderEpisode } from "./episode-runner.js";
21
+ import { cacheDirectory, cleanStaleDaemonMetadata, readConfig, readDaemonMetadata, stateDirectory, validateOrigin, writeConfig, } from "./state.js";
17
22
  import { CliError } from "./types.js";
18
23
  import { sourceHash, StudioWorkspace } from "./workspace.js";
19
24
  import { parseScenario, parseExperienceScenario } from "./scenario.js";
20
25
  import { audioLevel, audioSegments, encode, encodeArguments, findEncoder, mix, parseRecipe, probe, RENDER_KIND, sha256, verifyProbe } from "./video.js";
21
26
  import { changedPaths } from "./presence.js";
27
+ import { inspectNotice, PROJECT_FLAGS, projectCommand, projectId, SCOPED_SEARCH_FLAGS, scopeWarningNotice, scopeWarnings, searchNotice, searchParameters } from "./project.js";
22
28
  import { capabilitiesView, compatibilityReport, contextSummary, experiencePreviewSummary, experienceSchemaNotice, mathPlaneRequirement, resolveCreateTarget, schemaTopicView } from "./context.js";
23
- const VERSION = "0.8.0";
29
+ const VERSION = "0.9.1";
24
30
  const AGENT_PROTOCOL = 1;
25
31
  const INSTRUCTIONS = `# NerdBro Interactive Studio agent\n\nUse the provider-neutral \`nerdbro\` CLI. Before substantial authoring: read \`nerdbro context\`, fetch the live \`nerdbro guide\`, then search only relevant docs, exact contracts, Components, Behaviors and examples. Do not read the entire catalogue blindly.\n\nFor complex work, prove risky capabilities in a small spike first. Checkout once, edit only supported source, validate, compile, drive the learner scenario where supported, inspect visuals, save, certify, then stop for human approval. Never force a conflict, approve, publish, administer or deploy. Disconnect when work is over.\n\nIf this is a Git-backed workspace, inspect recent history first. Work in coherent batches; after each, run focused checks, inspect the diff, and commit the green checkpoint with intent and verification. Never force-push or rewrite another Agent's work.\n\nEverything visual is a Scene (\`studio create\` defaults to the sceneComposition target); a graph is a Scene math plane, never a separate target. Interaction is an Experience: final learner content containing one or more Scenes, with inputs, requests, readouts/exports, tools, rounds, presentation and assessment outcomes (game is scoring only). The targets and schemas \`nerdbro context\` lists as frozen (InteractiveScene, mathGraph, combined) are read for existing assets only. Read \`nerdbro schema experience\`, \`scene.stateMachines\`, and \`stateMachine.actions\` for current contracts. Library definitions (experience, audio, component, behavior) use \`create\` or \`checkout\`, edit \`source.json\`, then \`status\`, \`save\`, \`validate\` and \`certify\`. Audio must be AAC in MP4/M4A.\n\nCurrent live playbook: /docs/studio-language/agent-authoring-playbook\n`;
26
32
  // Presence-only options never consume the following positional argument.
27
- const BOOLEAN_FLAGS = new Set(["force", "full-page", "headless", "no-browser", "declarative", "assessment", "no-assessment", "scratch", "saved", "math-plane", "no-math-plane"]);
33
+ const BOOLEAN_FLAGS = new Set(["force", "full-page", "headless", "no-browser", "declarative", "assessment", "no-assessment", "scratch", "saved", "math-plane", "no-math-plane", "all-libraries", "full", "compact"]);
28
34
  export function parse(argv) {
29
35
  const args = [];
30
36
  const flags = new Map();
@@ -70,13 +76,17 @@ export function assertSupportedFlags(parsed) {
70
76
  if (command === "_daemon")
71
77
  allowed = ["origin", "headless", "no-browser", "version-tag"];
72
78
  else if (command === "attempt" && action === "start")
73
- allowed = ["label", "mode"];
79
+ allowed = ["label", "mode", "project"];
74
80
  else if (command === "attempt" && action === "finish")
75
81
  allowed = ["outcome"];
76
82
  else if (command === "agent" && action === "init")
77
83
  allowed = ["provider"];
78
- else if (["component", "behavior", "example"].includes(command) && action === "search")
84
+ else if (["component", "behavior"].includes(command) && action === "search")
85
+ allowed = SCOPED_SEARCH_FLAGS;
86
+ else if (command === "example" && action === "search")
79
87
  allowed = ["limit", "offset"];
88
+ else if (command === "project")
89
+ allowed = PROJECT_FLAGS[action || "list"] || [];
80
90
  else if (command === "world" && action === "inspect")
81
91
  allowed = ["zone"];
82
92
  else if (isLibraryKind(command)) {
@@ -99,6 +109,10 @@ export function assertSupportedFlags(parsed) {
99
109
  else if (action === "search")
100
110
  allowed = ["limit"];
101
111
  }
112
+ else if (command === "episode") {
113
+ if (["plan", "render", "status"].includes(action))
114
+ allowed = ["workspace", "out", "cache", "workers", "timeout"];
115
+ }
102
116
  else if (command === "studio") {
103
117
  if (action === "create")
104
118
  allowed = ["title", "target", ...BRIEF_FLAGS.filter((name) => name !== "no-assessment" && name !== "no-math-plane"), "command-id", "scratch", "starter"];
@@ -398,19 +412,34 @@ async function checkout(parsed, asset) {
398
412
  await target.write(source, lock);
399
413
  return { ok: true, command: "studio.checkout", asset, source: target.sourcePath, lock: target.lockPath, source_hash: hash, base_snapshot_public_id: lock.base_snapshot_public_id, authoring_brief: remote.authoring_brief || {}, authoring_warnings: remote.authoring_warnings || [] };
400
414
  }
415
+ /**
416
+ * The hash of the source the server serves, as the Studio page will hash it.
417
+ *
418
+ * A draft's stored hash was computed from the JSON it was saved with; read
419
+ * back from Postgres JSONB, a whole-number decimal such as 0.0 returns as 0,
420
+ * so the mounted Studio (which hashes what it loaded) can disagree with the
421
+ * stored value for older drafts. Browser mount checks therefore compare
422
+ * against the served source; conflict detection keeps the stored snapshot.
423
+ */
424
+ async function servedSourceHash(asset) {
425
+ const loaded = await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}`);
426
+ if (loaded.source && typeof loaded.source === "object")
427
+ return sourceHash(loaded.source);
428
+ return String(loaded.source_hash || "");
429
+ }
401
430
  async function studioStatus(parsed, asset) {
402
431
  const target = workspace(parsed, asset);
403
- const [source, lock, remote] = await Promise.all([target.source(), target.lock(), api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}/status`)]);
432
+ const [source, lock, remote, served] = await Promise.all([target.source(), target.lock(), api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}/status`), servedSourceHash(asset)]);
404
433
  const localHash = sourceHash(source);
405
434
  const remoteHead = remote.head_snapshot?.public_id || null;
406
- return { ok: true, command: "studio.status", asset, local_source_hash: localHash, local_changed: localHash !== lock.checkout_source_hash, base_snapshot_public_id: lock.base_snapshot_public_id, remote_snapshot_public_id: remoteHead, remote_source_hash: remote.source_hash, remote_changed: remoteHead !== lock.base_snapshot_public_id, conflict: remoteHead !== lock.base_snapshot_public_id && localHash !== lock.checkout_source_hash, authoring_warnings: remote.authoring_warnings || [] };
435
+ return { ok: true, command: "studio.status", asset, local_source_hash: localHash, local_changed: localHash !== lock.checkout_source_hash, base_snapshot_public_id: lock.base_snapshot_public_id, remote_snapshot_public_id: remoteHead, remote_source_hash: remote.source_hash, served_source_hash: served, remote_changed: remoteHead !== lock.base_snapshot_public_id, conflict: remoteHead !== lock.base_snapshot_public_id && localHash !== lock.checkout_source_hash, authoring_warnings: remote.authoring_warnings || [] };
407
436
  }
408
437
  async function assertBrowserSource(parsed, asset) {
409
438
  const target = workspace(parsed, asset);
410
439
  const source = await target.source();
411
440
  const hash = sourceHash(source);
412
441
  const status = await studioStatus(parsed, asset);
413
- if (status.local_changed || status.remote_source_hash !== hash)
442
+ if (status.local_changed || status.served_source_hash !== hash)
414
443
  throw new CliError("Save the local source before browser inspection; local, server, and runtime hashes must match.", "STUDIO_SOURCE_MISMATCH", 3, status);
415
444
  return { target, source, hash };
416
445
  }
@@ -812,6 +841,67 @@ async function readRecipe(parsed) {
812
841
  throw new CliError("Recipe is not valid JSON.", "VIDEO_RECIPE_INVALID", 2);
813
842
  }
814
843
  }
844
+ const PARTIAL_VIDEO = "video.partial.mp4";
845
+ const FAILURE_MANIFEST = "render-failed.json";
846
+ /**
847
+ * A failed render, in one place: remove what it wrote (frames unless
848
+ * --keep-frames, sound copies, the mix and any unverified MP4), write
849
+ * `render-failed.json`, and return an error that names the source, the stage
850
+ * that failed and that no output is reusable. A render.json and video.mp4
851
+ * from an earlier successful render into the same directory are left alone;
852
+ * this attempt never replaced them.
853
+ */
854
+ async function renderFailure(error, context) {
855
+ const failure = error instanceof CliError ? error : new CliError(String(error), "VIDEO_RENDER_FAILED");
856
+ const details = failure.details && typeof failure.details === "object" ? failure.details : {};
857
+ const stage = typeof details.stage === "string" ? details.stage : context.stage;
858
+ const { destination } = context;
859
+ if (!context.keepFrames)
860
+ await rm(path.join(destination, "frames"), { recursive: true, force: true }).catch(() => undefined);
861
+ for (const leftover of ["audio", "audio-mix.wav", PARTIAL_VIDEO]) {
862
+ await rm(path.join(destination, leftover), { recursive: true, force: true }).catch(() => undefined);
863
+ }
864
+ const report = {
865
+ kind: "kheleshikhi.video-render-failure.v1",
866
+ subject: context.subject,
867
+ ...context.identity,
868
+ stage,
869
+ code: failure.code,
870
+ message: failure.message,
871
+ output_reusable: false,
872
+ canonical: false,
873
+ frames_kept: context.keepFrames,
874
+ failed_at: new Date().toISOString(),
875
+ };
876
+ await writeFile(path.join(destination, FAILURE_MANIFEST), `${JSON.stringify(report, null, 2)}\n`).catch(() => undefined);
877
+ return new CliError(`Render failed at ${stage}: ${failure.message}`, failure.code, failure.exitCode, { ...details, stage, source: context.identity, output_reusable: false, failure_report: path.join(destination, FAILURE_MANIFEST) });
878
+ }
879
+ /**
880
+ * Capture a render's frames through a daemon job (see render-jobs.ts): short
881
+ * status polls instead of one request held open for minutes. Ctrl-C cancels
882
+ * the job; progress goes to stderr on a terminal.
883
+ */
884
+ async function renderFramesThroughDaemon(kind, legacyRoute, request, json) {
885
+ const interrupt = new AbortController();
886
+ const onInterrupt = () => interrupt.abort("interrupted");
887
+ process.once("SIGINT", onInterrupt);
888
+ let reported = 0;
889
+ const onProgress = (job) => {
890
+ if (json || !process.stderr.isTTY || Date.now() - reported < 5_000)
891
+ return;
892
+ reported = Date.now();
893
+ const { stage, frame, frames } = job.progress;
894
+ process.stderr.write(`nerdbro: rendering ${stage}${frames ? ` ${frame ?? 0}/${frames} frames` : ""} (${Math.round(job.elapsed_ms / 1000)} s)\n`);
895
+ };
896
+ try {
897
+ return await runRenderJob(
898
+ // Polls are short; only the pre-job fallback route may run long.
899
+ (route, init) => daemonRpc(route, route === legacyRoute ? init : { ...init, signal: AbortSignal.timeout(60_000) }), kind, legacyRoute, request, { signal: interrupt.signal, onProgress });
900
+ }
901
+ finally {
902
+ process.removeListener("SIGINT", onInterrupt);
903
+ }
904
+ }
815
905
  /**
816
906
  * Mix, encode, verify and describe one render. The audio is mixed from the
817
907
  * canonical bytes the runtime would have played, at the times it logged;
@@ -819,6 +909,15 @@ async function readRecipe(parsed) {
819
909
  * derivative of and how it was made.
820
910
  */
821
911
  async function finishRender(input) {
912
+ let stage = "audio_mix";
913
+ try {
914
+ return await finishRenderStages(input, (value) => { stage = value; });
915
+ }
916
+ catch (error) {
917
+ throw await renderFailure(error, { destination: input.destination, keepFrames: input.keepFrames, identity: input.identity, subject: input.subject, stage });
918
+ }
919
+ }
920
+ async function finishRenderStages(input, setStage) {
822
921
  const { encoder, recipe, destination, frames } = input;
823
922
  const durationMs = Math.round((frames.frames / recipe.fps) * 1000);
824
923
  const log = (frames.audio?.events ?? []);
@@ -834,15 +933,21 @@ async function finishRender(input) {
834
933
  mixArgs = await mix(encoder, segments, new Map(Object.entries(sources).map(([key, value]) => [key, value.file])), durationMs, mixed);
835
934
  }
836
935
  const encodeStarted = Date.now();
936
+ // The encode lands under a partial name and becomes video.mp4 only once it
937
+ // is verified: a failed render never leaves an MP4 that looks finished.
837
938
  const output = path.join(destination, "video.mp4");
838
- await encode(encoder, recipe, path.join(destination, "frames", "%06d.png"), output, mixed);
839
- const probed = await probe(encoder, output);
939
+ const partial = path.join(destination, PARTIAL_VIDEO);
940
+ setStage("encode");
941
+ await encode(encoder, recipe, path.join(destination, "frames", "%06d.png"), partial, mixed);
942
+ setStage("verify");
943
+ const probed = await probe(encoder, partial);
840
944
  verifyProbe(recipe, probed);
841
- const level = await audioLevel(encoder, output);
945
+ const level = await audioLevel(encoder, partial);
842
946
  if (segments.length && (level.max_db === null || level.max_db < -60)) {
843
947
  throw new CliError("Sounds were logged but the encoded audio track is silent.", "VIDEO_AUDIO_SILENT", 1, { segments: segments.length });
844
948
  }
845
- const bytes = await readFile(output);
949
+ setStage("manifest");
950
+ const bytes = await readFile(partial);
846
951
  const manifest = {
847
952
  kind: RENDER_KIND,
848
953
  subject: input.subject,
@@ -890,13 +995,16 @@ async function finishRender(input) {
890
995
  output: { file: "video.mp4", bytes: bytes.length, sha256: sha256(bytes), probe: probed },
891
996
  determinism: "Video times, learner actions and sound placements are exact. Pixels depend on the browser and GPU; compare renders by frame_sha256.",
892
997
  performance: { ...(frames.playback_timing ? { playback_timing: frames.playback_timing } : {}),
893
- capture_seconds: frames.capture_seconds, mix_seconds: (encodeStarted - mixStarted) / 1000,
998
+ capture_seconds: frames.capture_seconds, ...(frames.capture_phases ? { capture_phases: frames.capture_phases } : {}),
999
+ mix_seconds: (encodeStarted - mixStarted) / 1000,
894
1000
  encode_seconds: (Date.now() - encodeStarted) / 1000,
895
1001
  total_seconds: (Date.now() - input.started) / 1000, frames_per_second_captured: frames.frames / Math.max(frames.capture_seconds, 0.001),
896
1002
  agent_rss_mb: Math.round(process.memoryUsage().rss / 1048576) },
897
1003
  rendered_at: new Date().toISOString(),
898
1004
  };
1005
+ await rename(partial, output);
899
1006
  await writeFile(path.join(destination, "render.json"), `${JSON.stringify({ ...manifest, frame_sha256: frames.frame_sha256 }, null, 2)}\n`);
1007
+ await rm(path.join(destination, FAILURE_MANIFEST), { force: true });
900
1008
  if (!input.keepFrames)
901
1009
  await rm(path.join(destination, "frames"), { recursive: true, force: true });
902
1010
  if (mixed)
@@ -905,6 +1013,93 @@ async function finishRender(input) {
905
1013
  width: probed.width, height: probed.height, duration_seconds: probed.duration, audio_segments: segments.length,
906
1014
  audio_max_dbfs: level.max_db, sha256: manifest.output.sha256 };
907
1015
  }
1016
+ /** One Scene presentation render (see video.ts): capture through a daemon job, then mix, encode, verify, describe. */
1017
+ async function renderSceneVideo(input) {
1018
+ const { asset, candidate, recipe, encoder, destination, keepFrames } = input;
1019
+ const hash = sourceHash(candidate);
1020
+ await mkdir(destination, { recursive: true });
1021
+ const started = Date.now();
1022
+ const frames = await renderFramesThroughDaemon("scene", "/browser/render-frames", {
1023
+ asset, sourceHash: input.remoteHash, candidate, candidateHash: hash, recipe,
1024
+ framesDirectory: path.join(destination, "frames"), audioDirectory: path.join(destination, "audio"),
1025
+ measurePlaybackSeconds: input.measurePlaybackSeconds ?? 0, keepFramesOnFailure: keepFrames,
1026
+ out: path.join(destination, "mounted.png"), timeoutMs: input.timeoutMs
1027
+ }, input.json).catch(async (error) => {
1028
+ throw await renderFailure(error, { destination, keepFrames, subject: "scene", stage: "capture", identity: { asset, source_hash: hash } });
1029
+ });
1030
+ // The exact Component, Behavior and Audio definitions this source
1031
+ // compiles against right now (a shot cache keys on its digest).
1032
+ const compiledDependencies = await compileDependencies(api, asset, candidate).catch(() => null);
1033
+ return finishRender({
1034
+ subject: "scene", encoder, recipe, destination, frames, started, keepFrames,
1035
+ identity: { asset, source_hash: hash, source_revision: frames.source_revision ?? null, runtime_fingerprint: frames.runtime_fingerprint ?? null,
1036
+ dependencies_digest: compiledDependencies?.dependencies?.digest ?? null, dependencies: compiledDependencies?.dependencies ?? null },
1037
+ note: "A presentation derivative of the Scene below. The Scene Source stays the authority and stays interactive; this file is never Experience content, never certification evidence and never part of a KSPKG.",
1038
+ clock: "video time; the SceneClock follows it only while it plays (an authored pause holds the Scene)",
1039
+ extra: { scene_clock: frames.scene_clock },
1040
+ });
1041
+ }
1042
+ /** What an Experience render plays: the local source.json, or (with saved) the saved head the lock names. */
1043
+ async function experienceRenderSource(target, saved) {
1044
+ const result = { revision: null };
1045
+ try {
1046
+ const lock = await target.lock();
1047
+ result.revision = lock.head_revision_public_id || null;
1048
+ if (saved) {
1049
+ result.expectedRevision = result.revision || undefined;
1050
+ result.expectedHash = lock.source_hash;
1051
+ }
1052
+ }
1053
+ catch { /* No local checkout: render the saved revision. */ }
1054
+ if (!saved) {
1055
+ try {
1056
+ result.candidate = await target.source();
1057
+ result.candidateHash = sourceHash(result.candidate);
1058
+ }
1059
+ catch { /* No local source: render the saved revision. */ }
1060
+ }
1061
+ return result;
1062
+ }
1063
+ /** One Experience presentation render (see video.ts). */
1064
+ async function renderExperienceVideo(input) {
1065
+ const { stableId, recipe, encoder, destination, keepFrames, candidate, candidateHash, expectedRevision, expectedHash, revision } = input;
1066
+ await mkdir(destination, { recursive: true });
1067
+ const started = Date.now();
1068
+ const frames = await renderFramesThroughDaemon("experience", "/browser/render-experience-frames", {
1069
+ asset: stableId, sourceHash: "", recipe, timeoutMs: input.timeoutMs,
1070
+ framesDirectory: path.join(destination, "frames"), audioDirectory: path.join(destination, "audio"),
1071
+ out: path.join(destination, "mounted.png"), keepFramesOnFailure: keepFrames,
1072
+ library: { workspace: "experiences", selection: stableId, revision: expectedRevision, definitionHash: expectedHash },
1073
+ ...(candidateHash ? { candidate, candidateHash } : {})
1074
+ }, input.json).catch(async (error) => {
1075
+ throw await renderFailure(error, { destination, keepFrames, subject: "experience", stage: "capture",
1076
+ identity: { experience: stableId, source_hash: candidateHash ?? expectedHash ?? null, source_origin: candidateHash ? "local" : "saved" } });
1077
+ });
1078
+ const context = await api("GET", "/v1/agent/context").catch(() => ({}));
1079
+ const document = (candidate ?? {});
1080
+ const scenes = document.scenes && typeof document.scenes === "object" ? document.scenes : null;
1081
+ // Per-Scene identity (approved revision, stored manifest, source hash and
1082
+ // dependency digest) and every sound's sha256: what a shot cache keys on.
1083
+ const sceneProvenance = await (async () => {
1084
+ const rendered = candidate ?? (await api("GET", `${authoringRoute("experience", stableId)}?purpose=inspect`)).document;
1085
+ return experienceProvenance(api, rendered);
1086
+ })().catch((error) => ({ error: error instanceof Error ? error.message : String(error) }));
1087
+ return finishRender({
1088
+ subject: "experience", encoder, recipe, destination, frames, started, keepFrames,
1089
+ identity: {
1090
+ experience: stableId,
1091
+ source_hash: candidateHash ?? frames.source_hash ?? expectedHash ?? null,
1092
+ source_origin: candidateHash ? "local" : "saved",
1093
+ source_revision: revision,
1094
+ scenes,
1095
+ scene_provenance: sceneProvenance,
1096
+ scene_runtime_fingerprint: context.runtime_fingerprint ?? null,
1097
+ },
1098
+ note: "A presentation derivative of the Experience below. The Experience Source and its Scenes stay the authority and stay interactive; this file is never Experience content, never certification evidence and never part of a KSPKG.",
1099
+ clock: "video time (the Experience master timeline advances with it while it runs)",
1100
+ extra: { scenes_shown: frames.scenes, final_state: frames.final_state, final_scene: frames.final_scene, timeline_ms: frames.timeline_ms, completed: frames.completed },
1101
+ });
1102
+ }
908
1103
  async function libraryInspectBrowser(parsed, kind, stableId) {
909
1104
  const target = libraryWorkspace(parsed, kind, stableId);
910
1105
  const viewport = flag(parsed, "viewport", "desktop");
@@ -1029,56 +1224,10 @@ async function library(parsed, kind, action, argument) {
1029
1224
  const recipe = parseRecipe(await readRecipe(parsed), "experience");
1030
1225
  const encoder = await findEncoder();
1031
1226
  await ensureCompatibility();
1032
- let candidate;
1033
- let candidateHash;
1034
- let expectedRevision;
1035
- let expectedHash;
1036
- let revision = null;
1037
- try {
1038
- const lock = await target.lock();
1039
- revision = lock.head_revision_public_id || null;
1040
- if (parsed.flags.has("saved")) {
1041
- expectedRevision = revision || undefined;
1042
- expectedHash = lock.source_hash;
1043
- }
1044
- }
1045
- catch { /* No local checkout: render the saved revision. */ }
1046
- if (!parsed.flags.has("saved")) {
1047
- try {
1048
- candidate = await target.source();
1049
- candidateHash = sourceHash(candidate);
1050
- }
1051
- catch { /* No local source: render the saved revision. */ }
1052
- }
1227
+ const source = await experienceRenderSource(target, parsed.flags.has("saved"));
1053
1228
  const destination = path.resolve(flag(parsed, "out", `render-experience-${stableId}-${Date.now()}`));
1054
- await mkdir(destination, { recursive: true });
1055
- const started = Date.now();
1056
- const response = await daemonRpc("/browser/render-experience-frames", {
1057
- method: "POST",
1058
- body: JSON.stringify({ asset: stableId, sourceHash: "", recipe, timeoutMs: Number(flag(parsed, "timeout", "30000")),
1059
- framesDirectory: path.join(destination, "frames"), audioDirectory: path.join(destination, "audio"),
1060
- out: path.join(destination, "mounted.png"),
1061
- library: { workspace: "experiences", selection: stableId, revision: expectedRevision, definitionHash: expectedHash },
1062
- ...(candidateHash ? { candidate, candidateHash } : {}) }),
1063
- });
1064
- const frames = response.result;
1065
- const context = await api("GET", "/v1/agent/context").catch(() => ({}));
1066
- const document = (candidate ?? {});
1067
- const scenes = document.scenes && typeof document.scenes === "object" ? document.scenes : null;
1068
- const result = await finishRender({
1069
- subject: "experience", encoder, recipe, destination, frames, started, keepFrames: parsed.flags.has("keep-frames"),
1070
- identity: {
1071
- experience: stableId,
1072
- source_hash: candidateHash ?? frames.source_hash ?? expectedHash ?? null,
1073
- source_origin: candidateHash ? "local" : "saved",
1074
- source_revision: revision,
1075
- scenes,
1076
- scene_runtime_fingerprint: context.runtime_fingerprint ?? null,
1077
- },
1078
- note: "A presentation derivative of the Experience below. The Experience Source and its Scenes stay the authority and stay interactive; this file is never Experience content, never certification evidence and never part of a KSPKG.",
1079
- clock: "video time (the Experience master timeline advances with it while it runs)",
1080
- extra: { scenes_shown: frames.scenes, final_state: frames.final_state, final_scene: frames.final_scene, timeline_ms: frames.timeline_ms, completed: frames.completed },
1081
- });
1229
+ const result = await renderExperienceVideo({ stableId, ...source, recipe, encoder, destination,
1230
+ keepFrames: parsed.flags.has("keep-frames"), timeoutMs: Number(flag(parsed, "timeout", "30000")), json: parsed.json });
1082
1231
  return { ok: true, command: "experience.render-video", stable_id: stableId, ...result };
1083
1232
  }
1084
1233
  if (kind === "experience" && action === "watch") {
@@ -1123,6 +1272,172 @@ async function library(parsed, kind, action, argument) {
1123
1272
  return libraryExport(parsed, kind, stableId);
1124
1273
  return libraryInspectBrowser(parsed, kind, stableId);
1125
1274
  }
1275
+ // ---------------------------------------------------------------------------
1276
+ // Episodes (see episode.ts): validate | plan | render | status <episode.json>
1277
+ // ---------------------------------------------------------------------------
1278
+ export const EPISODE_MAX_WORKERS = 4;
1279
+ /**
1280
+ * Shots rendered at once: what was asked, but never more than the daemon has
1281
+ * render browsers. A daemon started without `NERDBRO_RENDER_WORKERS` owns one,
1282
+ * and a second concurrent shot would fail `VIDEO_RENDER_BUSY` mid-episode.
1283
+ */
1284
+ export function effectiveEpisodeWorkers(requested, daemonRenderWorkers) {
1285
+ const available = typeof daemonRenderWorkers === "number" && Number.isInteger(daemonRenderWorkers) && daemonRenderWorkers >= 1
1286
+ ? daemonRenderWorkers : 1;
1287
+ if (requested <= available)
1288
+ return { workers: requested, note: null };
1289
+ return {
1290
+ workers: available,
1291
+ note: `--workers ${requested} reduced to ${available}: the running daemon has ${available} render browser(s). `
1292
+ + `Restart it with NERDBRO_RENDER_WORKERS=${requested} (nerdbro agent stop) to render ${requested} shots at once.`,
1293
+ };
1294
+ }
1295
+ async function readEpisodeFile(file) {
1296
+ const episodePath = path.resolve(required(file, "episode.json is required: nerdbro episode <validate|plan|render|status> <episode.json>."));
1297
+ if ((await stat(episodePath)).size > 1_048_576)
1298
+ throw new CliError("episode.json exceeds 1 MiB.", "EPISODE_INVALID", 2);
1299
+ let raw;
1300
+ try {
1301
+ raw = JSON.parse(await readFile(episodePath, "utf8"));
1302
+ }
1303
+ catch {
1304
+ throw new CliError("episode.json is not valid JSON.", "EPISODE_INVALID", 2);
1305
+ }
1306
+ return { episodePath, episode: parseEpisode(raw) };
1307
+ }
1308
+ /** Resolve one shot's exact identity from the live server (never from file times). */
1309
+ async function resolveEpisodeShot(parsed, shot) {
1310
+ const failed = (message, code = "EPISODE_SHOT_UNRESOLVED") => new CliError(`Shot ${shot.id}: ${message}`, code, 2, { shot: shot.id, stage: "resolve" });
1311
+ if (shot.scene) {
1312
+ const { asset } = shot.scene;
1313
+ let candidate;
1314
+ let remoteHash = "";
1315
+ let revision = null;
1316
+ if (shot.scene.source === "approved") {
1317
+ const loaded = await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}`);
1318
+ if (loaded.source_origin !== "approved" || !loaded.source)
1319
+ throw failed("the asset's current source is not its approved revision (a draft is open); render source local or approve it first.");
1320
+ if (shot.scene.revision && loaded.base_revision_public_id !== shot.scene.revision)
1321
+ throw failed(`revision ${shot.scene.revision} is not the asset's approved head ${loaded.base_revision_public_id}.`);
1322
+ candidate = loaded.source;
1323
+ remoteHash = sourceHash(candidate);
1324
+ revision = loaded.base_revision_public_id ?? null;
1325
+ }
1326
+ else {
1327
+ candidate = await workspace(parsed, asset).source();
1328
+ remoteHash = await servedSourceHash(asset);
1329
+ }
1330
+ const hash = sourceHash(candidate);
1331
+ const compiled = await compileDependencies(api, asset, candidate);
1332
+ if (!compiled.compiled)
1333
+ throw failed("the Scene source does not compile; run `nerdbro studio validate`.", "EPISODE_SHOT_SOURCE_INVALID");
1334
+ const audio = Object.fromEntries((compiled.dependencies?.audio ?? []).map((item) => [item.id, item.sha256 ?? "unresolved"]));
1335
+ return {
1336
+ sources: { main: hash },
1337
+ dependency_digests: { main: compiled.dependencies?.digest ?? null },
1338
+ audio_sha256: audio,
1339
+ identity: { asset, source: shot.scene.source, source_hash: hash, revision, dependencies_digest: compiled.dependencies?.digest ?? null,
1340
+ components: compiled.dependencies?.components ?? [], behaviors: compiled.dependencies?.behaviors ?? [] },
1341
+ render: { asset, candidate, remoteHash },
1342
+ };
1343
+ }
1344
+ const id = shot.experience.id;
1345
+ const target = libraryWorkspace(parsed, "experience", id);
1346
+ const source = await experienceRenderSource(target, shot.experience.source === "saved");
1347
+ const document = source.candidate ?? (await api("GET", `${authoringRoute("experience", id)}?purpose=inspect`)).document;
1348
+ if (!document)
1349
+ throw failed("the Experience has no readable source.");
1350
+ const provenance = await experienceProvenance(api, document);
1351
+ const sources = { experience: source.candidateHash ?? source.expectedHash ?? sourceHash(document) };
1352
+ const digests = {};
1353
+ for (const [scene, identity] of Object.entries(provenance.scenes)) {
1354
+ sources[`scene:${scene}`] = digestOf({ asset: identity.asset ?? null, revision: identity.revision ?? null, manifest: identity.manifest_path ?? null,
1355
+ package: identity.package ?? null, package_sha256: identity.package_sha256 ?? null, source_hash: identity.source_hash ?? null });
1356
+ digests[scene] = identity.dependencies_digest;
1357
+ }
1358
+ return {
1359
+ sources, dependency_digests: digests, audio_sha256: provenance.audio,
1360
+ identity: { experience: id, source: shot.experience.source, scene_provenance: provenance },
1361
+ render: source,
1362
+ };
1363
+ }
1364
+ async function renderEpisodeShot(parsed, encoder, shot, recipe, resolved, destination) {
1365
+ const timeoutMs = Number(flag(parsed, "timeout", "30000"));
1366
+ if (shot.scene) {
1367
+ const render = resolved.render;
1368
+ await renderSceneVideo({ ...render, recipe, encoder, destination, keepFrames: false, timeoutMs, json: true });
1369
+ }
1370
+ else {
1371
+ await renderExperienceVideo({ stableId: shot.experience.id, ...resolved.render, recipe, encoder, destination,
1372
+ keepFrames: false, timeoutMs, json: true });
1373
+ }
1374
+ return { output: path.join(destination, "video.mp4"), manifest: path.join(destination, "render.json") };
1375
+ }
1376
+ async function episodeCommand(parsed, action, file) {
1377
+ if (!action || !["validate", "plan", "render", "status"].includes(action)) {
1378
+ throw new CliError("Usage: nerdbro episode <validate|plan|render|status> <episode.json>.", "AGENT_COMMAND_UNKNOWN", 2);
1379
+ }
1380
+ const { episodePath, episode } = await readEpisodeFile(file);
1381
+ const timeline = episodeTimeline(episode);
1382
+ if (action === "validate") {
1383
+ return { ok: true, command: "episode.validate", episode: episode.id, title: episode.title, shots: episode.shots.length,
1384
+ total_frames: timeline.total_frames, total_seconds: timeline.total_seconds,
1385
+ timeline: timeline.shots.map((item) => ({ id: item.id, frames: item.frames, offset_seconds: item.offset_seconds })),
1386
+ subtitles: episodeCues(episode, timeline).length };
1387
+ }
1388
+ const out = path.resolve(flag(parsed, "out", path.join(path.dirname(episodePath), `episode-out-${episode.id}`)));
1389
+ const cache = path.resolve(flag(parsed, "cache", path.join(cacheDirectory(), "episode-shots")));
1390
+ const workers = Number(flag(parsed, "workers", "1"));
1391
+ if (!Number.isInteger(workers) || workers < 1 || workers > EPISODE_MAX_WORKERS) {
1392
+ throw new CliError(`--workers must be 1 to ${EPISODE_MAX_WORKERS}.`, "AGENT_ARGUMENT_INVALID", 2);
1393
+ }
1394
+ const encoder = await findEncoder();
1395
+ await ensureCompatibility();
1396
+ const context = await api("GET", "/v1/agent/context");
1397
+ const deps = {
1398
+ encoder, runtimeFingerprint: context.runtime_fingerprint ?? null,
1399
+ resolveShot: (shot) => resolveEpisodeShot(parsed, shot),
1400
+ renderShot: (shot, recipe, resolved, destination) => renderEpisodeShot(parsed, encoder, shot, recipe, resolved, destination),
1401
+ log: (message) => { if (!parsed.json && process.stderr.isTTY)
1402
+ process.stderr.write(`nerdbro: ${message}\n`); },
1403
+ };
1404
+ const options = { episode, baseDirectory: path.dirname(episodePath), out, cache, workers, deps };
1405
+ if (action === "plan" || action === "status") {
1406
+ const plan = await planEpisode(options);
1407
+ let state = null;
1408
+ try {
1409
+ state = JSON.parse(await readFile(path.join(out, "episode-state.json"), "utf8"));
1410
+ }
1411
+ catch { /* never rendered here */ }
1412
+ let output = null;
1413
+ try {
1414
+ const manifest = JSON.parse(await readFile(path.join(out, "episode.json"), "utf8"));
1415
+ const current = manifest.shots?.map((shot) => shot.key).join() === plan.shots.map((shot) => shot.key).join();
1416
+ output = { file: path.join(out, `${episode.id}.mp4`), sha256: manifest.output?.sha256 ?? null, matches_current_shots: current,
1417
+ qc_passed: Array.isArray(manifest.qc) ? manifest.qc.every((check) => check.pass) : null };
1418
+ }
1419
+ catch { /* no assembled output */ }
1420
+ return { ok: true, command: `episode.${action}`, episode: episode.id, out, cache,
1421
+ total_frames: plan.timeline.total_frames, total_seconds: plan.timeline.total_seconds,
1422
+ shots: plan.shots.map((shot) => ({ id: shot.id, subject: shot.subject, key: shot.key, frames: shot.frames, cache: shot.cache,
1423
+ ...(shot.cache_reason ? { cache_reason: shot.cache_reason } : {}), offset_seconds: shot.offset_seconds })),
1424
+ would_render: plan.shots.filter((shot) => shot.cache !== "valid").map((shot) => shot.id),
1425
+ ...(action === "status" ? { state, output } : {}) };
1426
+ }
1427
+ const daemon = await startDaemon(true);
1428
+ const concurrency = effectiveEpisodeWorkers(workers, daemon.render_workers);
1429
+ if (concurrency.note)
1430
+ deps.log?.(concurrency.note);
1431
+ const result = await renderEpisode({ ...options, workers: concurrency.workers });
1432
+ if (!result.ok) {
1433
+ throw new CliError("The episode was assembled but failed QC; see qc.json.", "EPISODE_QC_FAILED", 1, { output: result.output, manifest: result.manifest, qc: result.qc?.filter((check) => !check.pass) });
1434
+ }
1435
+ return { ok: result.ok, command: "episode.render", episode: episode.id, output: result.output, srt: result.srt, manifest: result.manifest,
1436
+ state: result.state, reused: result.reused, rendered: result.rendered,
1437
+ shots: result.shots.map((shot) => ({ id: shot.id, key: shot.key, state: shot.state, render_seconds: shot.render_seconds ?? null })),
1438
+ qc: result.qc, timings: result.timings, workers: concurrency.workers,
1439
+ ...(concurrency.note ? { workers_note: concurrency.note } : {}) };
1440
+ }
1126
1441
  async function run(parsed) {
1127
1442
  const [first, second, third] = parsed.args;
1128
1443
  assertSupportedFlags(parsed);
@@ -1144,13 +1459,14 @@ async function run(parsed) {
1144
1459
  if (await readAttemptPointer())
1145
1460
  throw new CliError("This workspace already has an Attempt.", "AGENT_ATTEMPT_ALREADY_ACTIVE", 3);
1146
1461
  const origin = (await readConfig()).apiOrigin;
1147
- const server = await api("POST", "/v1/agent/attempts", { label, mode, server_origin: origin });
1462
+ const project = flag(parsed, "project");
1463
+ const server = await api("POST", "/v1/agent/attempts", { label, mode, server_origin: origin, ...(project ? { project: projectId(project) } : {}) });
1148
1464
  const pointer = await createAttemptPointer(server, origin);
1149
- return { ok: true, command: "attempt.start", attempt_id: pointer.attempt_id, label, mode, folder: pointer.attempt_root, pointer: path.join(process.cwd(), ".nerdbro-attempt.json") };
1465
+ return { ok: true, command: "attempt.start", attempt_id: pointer.attempt_id, label, mode, folder: pointer.attempt_root, pointer: path.join(process.cwd(), ".nerdbro-attempt.json"), ...(project ? { project: pointer.project || null, project_scope: server.project_scope ?? null } : {}) };
1150
1466
  }
1151
1467
  if (second === "current") {
1152
1468
  const active = await readAttemptPointer();
1153
- return { ok: true, command: "attempt.current", attempt_id: active?.pointer.attempt_id || null };
1469
+ return { ok: true, command: "attempt.current", attempt_id: active?.pointer.attempt_id || null, ...(active?.pointer.project ? { project: active.pointer.project } : {}) };
1154
1470
  }
1155
1471
  if (second === "status") {
1156
1472
  const active = await readAttemptPointer();
@@ -1248,18 +1564,24 @@ async function run(parsed) {
1248
1564
  const plural = `${first}s`;
1249
1565
  if (second === "search") {
1250
1566
  const query = parsed.args.slice(2).join(" ");
1251
- const parameters = new URLSearchParams({ query });
1252
- const limit = flag(parsed, "limit");
1253
- const offset = flag(parsed, "offset");
1254
- if (limit)
1255
- parameters.set("limit", limit);
1256
- if (offset)
1257
- parameters.set("offset", offset);
1258
- return { ok: true, command: `${first}.search`, result: await api("GET", `/v1/agent/catalogue/${plural}/search?${parameters}`) };
1567
+ // Project Attempts get the compact scoped view by default (server-side); see project.ts.
1568
+ const parameters = searchParameters(query, (name) => flag(parsed, name), (name) => parsed.flags.has(name));
1569
+ const result = await api("GET", `/v1/agent/catalogue/${plural}/search?${parameters}`);
1570
+ const notice = searchNotice(result, first);
1571
+ if (notice && !parsed.json)
1572
+ process.stderr.write(`nerdbro: ${notice}\n`);
1573
+ return { ok: true, command: `${first}.search`, result };
1574
+ }
1575
+ if (second === "inspect") {
1576
+ const result = await api("GET", `/v1/agent/catalogue/${plural}/${encodeURIComponent(required(third, "ID is required."))}`);
1577
+ const notice = inspectNotice(result, first);
1578
+ if (notice && !parsed.json)
1579
+ process.stderr.write(`nerdbro: ${notice}\n`);
1580
+ return { ok: true, command: `${first}.inspect`, result };
1259
1581
  }
1260
- if (second === "inspect")
1261
- return { ok: true, command: `${first}.inspect`, result: await api("GET", `/v1/agent/catalogue/${plural}/${encodeURIComponent(required(third, "ID is required."))}`) };
1262
1582
  }
1583
+ if (first === "project")
1584
+ return projectCommand(second, third, (name) => flag(parsed, name), api, (await readAttemptPointer())?.pointer.project);
1263
1585
  if (first === "world") {
1264
1586
  // Live World discovery; no World list is bundled with this package.
1265
1587
  if (!second || second === "list")
@@ -1276,6 +1598,8 @@ async function run(parsed) {
1276
1598
  if (result !== undefined)
1277
1599
  return result;
1278
1600
  }
1601
+ if (first === "episode")
1602
+ return episodeCommand(parsed, second, third);
1279
1603
  if (first === "studio") {
1280
1604
  const action = required(second, "Studio command is required.");
1281
1605
  if (action === "create") {
@@ -1360,8 +1684,15 @@ async function run(parsed) {
1360
1684
  }
1361
1685
  if (action === "source")
1362
1686
  return { ok: true, command: "studio.source", asset, source: await target.source() };
1363
- if (["validate", "compile", "preview"].includes(action))
1364
- return { ok: true, command: `studio.${action}`, asset, source_hash: sourceHash(await target.source()), result: await api("POST", `/v1/agent/studio/${encodeURIComponent(asset)}/${action}`, { source: await target.source() }) };
1687
+ if (["validate", "compile", "preview"].includes(action)) {
1688
+ const result = await api("POST", `/v1/agent/studio/${encodeURIComponent(asset)}/${action}`, { source: await target.source() });
1689
+ // Project scope: out-of-scope dependencies warn here and block approval later.
1690
+ const warnings = scopeWarnings(result);
1691
+ const notice = scopeWarningNotice(warnings);
1692
+ if (notice && !parsed.json)
1693
+ process.stderr.write(`nerdbro: ${notice}\n`);
1694
+ return { ok: true, command: `studio.${action}`, asset, source_hash: sourceHash(await target.source()), result, ...(warnings.length ? { scope_warnings: warnings } : {}) };
1695
+ }
1365
1696
  if (action === "history")
1366
1697
  return { ok: true, command: "studio.history", asset, result: await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}/history`) };
1367
1698
  if (action === "save") {
@@ -1426,7 +1757,7 @@ async function run(parsed) {
1426
1757
  const candidate = await target.source();
1427
1758
  const hash = sourceHash(candidate);
1428
1759
  const remote = await studioStatus(parsed, asset);
1429
- const remoteHash = String(remote.remote_source_hash || "");
1760
+ const remoteHash = String(remote.served_source_hash || remote.remote_source_hash || "");
1430
1761
  // A new scratch asset deliberately has no server source yet. The
1431
1762
  // browser opens its empty Studio route, then receives the unsaved local
1432
1763
  // candidate through the bounded authoring/test bridge.
@@ -1470,28 +1801,14 @@ async function run(parsed) {
1470
1801
  const candidate = await target.source();
1471
1802
  const hash = sourceHash(candidate);
1472
1803
  const remote = await studioStatus(parsed, asset);
1473
- const remoteHash = String(remote.remote_source_hash || "");
1804
+ const remoteHash = String(remote.served_source_hash || remote.remote_source_hash || "");
1474
1805
  if (!remoteHash && remote.remote_snapshot_public_id) {
1475
1806
  throw new CliError("Studio has a draft without a readable source.", "STUDIO_SOURCE_UNAVAILABLE", 3);
1476
1807
  }
1477
1808
  const destination = path.resolve(flag(parsed, "out", `render-${asset}-${Date.now()}`));
1478
- await mkdir(destination, { recursive: true });
1479
- const started = Date.now();
1480
- const response = await daemonRpc("/browser/render-frames", {
1481
- method: "POST",
1482
- body: JSON.stringify({ asset, sourceHash: remoteHash, candidate, candidateHash: hash, recipe,
1483
- framesDirectory: path.join(destination, "frames"), audioDirectory: path.join(destination, "audio"),
1484
- measurePlaybackSeconds: Number(flag(parsed, "measure-playback", "0")),
1485
- out: path.join(destination, "mounted.png"), timeoutMs: Number(flag(parsed, "timeout", "30000")) }),
1486
- });
1487
- const frames = response.result;
1488
- const result = await finishRender({
1489
- subject: "scene", encoder, recipe, destination, frames, started, keepFrames: parsed.flags.has("keep-frames"),
1490
- identity: { asset, source_hash: hash, source_revision: frames.source_revision ?? null, runtime_fingerprint: frames.runtime_fingerprint ?? null },
1491
- note: "A presentation derivative of the Scene below. The Scene Source stays the authority and stays interactive; this file is never Experience content, never certification evidence and never part of a KSPKG.",
1492
- clock: "video time; the SceneClock follows it only while it plays (an authored pause holds the Scene)",
1493
- extra: { scene_clock: frames.scene_clock },
1494
- });
1809
+ const result = await renderSceneVideo({ asset, candidate, remoteHash, recipe, encoder, destination,
1810
+ keepFrames: parsed.flags.has("keep-frames"), timeoutMs: Number(flag(parsed, "timeout", "30000")),
1811
+ measurePlaybackSeconds: Number(flag(parsed, "measure-playback", "0")), json: parsed.json });
1495
1812
  return { ok: true, command: "studio.render-video", asset, source_hash: hash, ...result };
1496
1813
  }
1497
1814
  if (action === "capture-preview") {
@@ -1557,6 +1874,15 @@ function help() {
1557
1874
  " docs | docs search <query> | docs open <slug>",
1558
1875
  " agent start|status|stop|instructions|init --provider claude|codex|generic",
1559
1876
  " component|behavior|example search <query>|inspect <id>",
1877
+ " component|behavior search [--limit n] [--offset n] [--all-libraries] [--full | --compact]",
1878
+ " in a project Attempt search is compact and scoped; stderr names related ids outside the scope",
1879
+ "",
1880
+ "Authoring projects (the libraries an Attempt may author from):",
1881
+ " project list | project libraries [<project>] | project show [<project>] | project current",
1882
+ " project create --title <text> --output-target lesson|game|experience|video --home-library <slug> [--purpose <text>] [--permitted a,b]",
1883
+ " attempt start --label <text> --project <project> scope every later command in this workspace",
1884
+ " project import <stable-id> [--kind component|behavior|audio|experience] --reason <text> [--project <project>]",
1885
+ "",
1560
1886
  " studio checkout|status|source|history|validate|compile|preview|save <asset>",
1561
1887
  " studio create --scratch --title <text> --concept <text> [--target sceneComposition] [--starter world:<world>/<zone>] [--command-id <id>]",
1562
1888
  " --target defaults to sceneComposition; targets the server lists as frozen (combined, mathGraph) are refused",
@@ -1573,6 +1899,12 @@ function help() {
1573
1899
  " presentation MP4 (H.264/AAC) of the learner surface at exact SceneClock times; needs local FFmpeg; writes render.json",
1574
1900
  " studio export <asset> --revision <revision> [--out file.kspkg]",
1575
1901
  "",
1902
+ "Episodes (kheleshikhi.episode.v1: ordered Scene/Experience shots, narration, music, subtitles):",
1903
+ " episode validate <episode.json> check the manifest and its timeline",
1904
+ " episode plan|status <episode.json> [--out dir] [--cache dir] shot keys, cache hits, what would render",
1905
+ " episode render <episode.json> [--out dir] [--cache dir] [--workers 1-4]",
1906
+ " renders changed shots only, then concat + ducked mix + loudnorm + SRT + QC; needs local FFmpeg",
1907
+ "",
1576
1908
  "Library authoring (<kind> = experience|audio|component|behavior):",
1577
1909
  " experience|audio search <query> [--limit n] | inspect <stable-id>",
1578
1910
  " audio upload <file.m4a|file.mp4> AAC in MP4/M4A, at most 24 MiB",