@kheleshikhi/studio-agent 0.6.1 → 0.8.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.
package/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
- import { realpathSync } from "node:fs";
4
+ import { realpathSync, watch as watchFile } from "node:fs";
5
5
  import { createRequire } from "node:module";
6
- import { access, appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
6
+ import { access, appendFile, mkdir, readFile, 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";
@@ -16,12 +16,15 @@ import { daemonRpc, setCliTrace } from "./protocol.js";
16
16
  import { cleanStaleDaemonMetadata, readConfig, readDaemonMetadata, stateDirectory, validateOrigin, writeConfig, } from "./state.js";
17
17
  import { CliError } from "./types.js";
18
18
  import { sourceHash, StudioWorkspace } from "./workspace.js";
19
- import { parseScenario } from "./scenario.js";
20
- const VERSION = "0.6.1";
19
+ import { parseScenario, parseExperienceScenario } from "./scenario.js";
20
+ import { audioLevel, audioSegments, encode, encodeArguments, findEncoder, mix, parseRecipe, probe, RENDER_KIND, sha256, verifyProbe } from "./video.js";
21
+ import { changedPaths } from "./presence.js";
22
+ import { capabilitiesView, compatibilityReport, contextSummary, experiencePreviewSummary, experienceSchemaNotice, mathPlaneRequirement, resolveCreateTarget, schemaTopicView } from "./context.js";
23
+ const VERSION = "0.8.0";
21
24
  const AGENT_PROTOCOL = 1;
22
- 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\nExperience is final learner content: it contains one or more Scenes, and each Scene owns local interaction and Bands. 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`;
25
+ 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`;
23
26
  // Presence-only options never consume the following positional argument.
24
- const BOOLEAN_FLAGS = new Set(["force", "full-page", "headless", "no-browser", "declarative", "assessment", "no-assessment", "scratch"]);
27
+ const BOOLEAN_FLAGS = new Set(["force", "full-page", "headless", "no-browser", "declarative", "assessment", "no-assessment", "scratch", "saved", "math-plane", "no-math-plane"]);
25
28
  export function parse(argv) {
26
29
  const args = [];
27
30
  const flags = new Map();
@@ -59,7 +62,7 @@ function flag(parsed, name, fallback) {
59
62
  const value = parsed.flags.get(name);
60
63
  return typeof value === "string" ? value : fallback;
61
64
  }
62
- const BRIEF_FLAGS = ["concept", "teaching-purpose", "learner-action", "expected-visual-behavior", "graph-purpose", "graph-x", "graph-y", "graph-sync", "language", "additional-instruction", "components", "behavior", "important-values", "assessment", "no-assessment"];
65
+ const BRIEF_FLAGS = ["concept", "teaching-purpose", "learner-action", "expected-visual-behavior", "graph-purpose", "graph-x", "graph-y", "graph-sync", "language", "additional-instruction", "components", "behavior", "important-values", "assessment", "no-assessment", "math-plane", "no-math-plane"];
63
66
  const BROWSER_FLAGS = ["view", "viewport", "timeout"];
64
67
  export function assertSupportedFlags(parsed) {
65
68
  const [command, action, detail] = parsed.args;
@@ -74,6 +77,8 @@ export function assertSupportedFlags(parsed) {
74
77
  allowed = ["provider"];
75
78
  else if (["component", "behavior", "example"].includes(command) && action === "search")
76
79
  allowed = ["limit", "offset"];
80
+ else if (command === "world" && action === "inspect")
81
+ allowed = ["zone"];
77
82
  else if (isLibraryKind(command)) {
78
83
  if (action === "create")
79
84
  allowed = ["workspace", "name", "category", "visual", "palette", "implementation", "media", "language", "description", "kind", "master-audio", "declarative", "duration-ms", "scene-asset", "scene-package", "scene", "document"];
@@ -83,6 +88,12 @@ export function assertSupportedFlags(parsed) {
83
88
  allowed = ["workspace", "revision", "out"];
84
89
  else if (action === "inspect-browser")
85
90
  allowed = ["workspace", "viewport", "timeout"];
91
+ else if (command === "experience" && action === "drive")
92
+ allowed = ["workspace", "scenario", "viewport", "timeout", "out", "saved"];
93
+ else if (command === "experience" && action === "watch")
94
+ allowed = ["workspace", "once", "interval"];
95
+ else if (command === "experience" && action === "render-video")
96
+ allowed = ["workspace", "recipe", "out", "timeout", "keep-frames", "saved"];
86
97
  else if (["status", "save", "validate", "certify", "preview"].includes(action))
87
98
  allowed = ["workspace"];
88
99
  else if (action === "search")
@@ -90,7 +101,7 @@ export function assertSupportedFlags(parsed) {
90
101
  }
91
102
  else if (command === "studio") {
92
103
  if (action === "create")
93
- allowed = ["title", "target", ...BRIEF_FLAGS.filter((name) => name !== "no-assessment"), "command-id", "scratch"];
104
+ allowed = ["title", "target", ...BRIEF_FLAGS.filter((name) => name !== "no-assessment" && name !== "no-math-plane"), "command-id", "scratch", "starter"];
94
105
  else if (action === "brief" && detail === "update")
95
106
  allowed = BRIEF_FLAGS;
96
107
  else if (action === "checkout")
@@ -103,10 +114,14 @@ export function assertSupportedFlags(parsed) {
103
114
  allowed = ["workspace", "scenario", "viewport", "timeout", "out", "view"];
104
115
  else if (action === "capture-preview")
105
116
  allowed = ["workspace", "out", "viewport", "mode", "duration", "fps", "timeout"];
117
+ else if (action === "render-video")
118
+ allowed = ["workspace", "recipe", "out", "timeout", "keep-frames", "measure-playback"];
106
119
  else if (action === "certify")
107
120
  allowed = ["workspace", ...BROWSER_FLAGS];
108
121
  else if (action === "export")
109
122
  allowed = ["workspace", "revision", "out"];
123
+ else if (action === "watch")
124
+ allowed = ["workspace", "once", "interval"];
110
125
  else
111
126
  allowed = ["workspace"];
112
127
  }
@@ -128,33 +143,15 @@ function output(value, asJson) {
128
143
  else
129
144
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
130
145
  }
131
- function versionParts(value) {
132
- const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value);
133
- if (!match)
134
- throw new CliError(`Server returned an invalid CLI version: ${value}`, "AGENT_VERSION_INVALID", 5);
135
- return match.slice(1).map(Number);
136
- }
137
- function compareVersions(left, right) {
138
- const a = versionParts(left);
139
- const b = versionParts(right);
140
- for (let index = 0; index < 3; index += 1) {
141
- if (a[index] !== b[index])
142
- return a[index] - b[index];
143
- }
144
- return 0;
145
- }
146
+ /** Refuse an incompatible CLI and warn when the server recommends a newer one. */
146
147
  export function assertCompatibility(context) {
147
- if (Number(context.agent_protocol) !== AGENT_PROTOCOL) {
148
- throw new CliError(`Agent protocol ${context.agent_protocol} is incompatible with CLI protocol ${AGENT_PROTOCOL}. Upgrade the NerdBro Studio Agent.`, "AGENT_PROTOCOL_INCOMPATIBLE", 5, context);
149
- }
150
- const minimum = String(context.minimum_cli_version || "");
151
- if (compareVersions(VERSION, minimum) < 0) {
152
- throw new CliError(`CLI ${VERSION} is too old; NerdBro requires ${minimum} or newer.`, "AGENT_CLI_UPGRADE_REQUIRED", 5, context);
153
- }
154
- const recommended = String(context.recommended_cli_version || minimum);
155
- if (compareVersions(VERSION, recommended) < 0) {
156
- process.stderr.write(`nerdbro: CLI ${recommended} is recommended (current ${VERSION}).\n`);
157
- }
148
+ const report = compatibilityReport(context, VERSION, AGENT_PROTOCOL);
149
+ if (report.status === "protocol_incompatible")
150
+ throw new CliError(report.message, "AGENT_PROTOCOL_INCOMPATIBLE", 5, { ...context, compatibility: report });
151
+ if (report.status === "upgrade_required")
152
+ throw new CliError(report.message, "AGENT_CLI_UPGRADE_REQUIRED", 5, { ...context, compatibility: report });
153
+ if (report.status === "upgrade_recommended")
154
+ process.stderr.write(`nerdbro: ${report.message}\n`);
158
155
  }
159
156
  export function certificationRuntimeObservation(observation) {
160
157
  const allowed = [
@@ -162,7 +159,12 @@ export function certificationRuntimeObservation(observation) {
162
159
  "runtime_fingerprint", "runtime_part_id", "active_states",
163
160
  "state_machine_error", "math_graph", "environment", "error_summary",
164
161
  ];
165
- return Object.fromEntries(allowed.filter((key) => observation[key] !== undefined).map((key) => [key, observation[key]]));
162
+ const certified = Object.fromEntries(allowed.filter((key) => observation[key] !== undefined).map((key) => [key, observation[key]]));
163
+ // The server refuses a probed observation; say honestly whether this was one.
164
+ const probe = observation.probe;
165
+ if (probe && typeof probe === "object")
166
+ certified.probe = { active: probe.active === true };
167
+ return certified;
166
168
  }
167
169
  export function structuredErrorDetails(value) {
168
170
  if (value && typeof value === "object" && !Array.isArray(value)) {
@@ -236,12 +238,19 @@ async function uploadApi(route, extension, contentType, bytes) {
236
238
  }
237
239
  }
238
240
  let compatibilityChecked = false;
241
+ // The last live context this process read: target, deprecation and capability
242
+ // authority for this command. Never persisted, never bundled.
243
+ let serverContext = null;
239
244
  async function ensureCompatibility() {
240
245
  const context = await rawApi("GET", "/v1/agent/context");
241
246
  assertCompatibility(context);
242
247
  compatibilityChecked = true;
248
+ serverContext = context;
243
249
  return context;
244
250
  }
251
+ async function liveContext() {
252
+ return serverContext ?? ensureCompatibility();
253
+ }
245
254
  async function api(method, route, body) {
246
255
  if (!compatibilityChecked && route !== "/v1/agent/context")
247
256
  await ensureCompatibility();
@@ -249,6 +258,7 @@ async function api(method, route, body) {
249
258
  if (route === "/v1/agent/context") {
250
259
  assertCompatibility(result);
251
260
  compatibilityChecked = true;
261
+ serverContext = result;
252
262
  }
253
263
  return result;
254
264
  }
@@ -407,6 +417,109 @@ async function assertBrowserSource(parsed, asset) {
407
417
  async function reportBrowserActivity(asset, operation, metadata) {
408
418
  await api("POST", `/v1/agent/studio/${encodeURIComponent(asset)}/activity`, { operation, ...metadata });
409
419
  }
420
+ async function reportExperienceActivity(stableId, operation, metadata) {
421
+ await api("POST", `/v1/agent/authoring/experience/${encodeURIComponent(stableId)}/activity`, { operation, ...metadata });
422
+ }
423
+ /** Agent Presence is supplementary: a failure never fails the command. */
424
+ async function setPresence(patch) {
425
+ try {
426
+ await api("PUT", "/v1/agent/presence", patch);
427
+ }
428
+ catch { /* Presence never blocks authoring. */ }
429
+ }
430
+ const PRESENCE_STATUS = {
431
+ validate: "validating", compile: "compiling", preview: "previewing", save: "saving",
432
+ certify: "certifying", drive: "probing", inspect: "capturing", screenshot: "capturing",
433
+ observe: "capturing", "capture-preview": "capturing", "inspect-browser": "capturing",
434
+ "render-video": "capturing",
435
+ };
436
+ /** Which presence a command announces, if it is one the Maker can watch. */
437
+ export function presencePlan(args) {
438
+ const [command, action, id] = args;
439
+ const status = action ? PRESENCE_STATUS[action] : undefined;
440
+ if (!status || !id || !/^[A-Za-z0-9_.-]{1,80}$/.test(id))
441
+ return null;
442
+ if (command === "studio")
443
+ return { subject: `asset:${id}`, status, op: action };
444
+ if (command === "experience" && action !== "inspect")
445
+ return { subject: `experience:${id}`, status, op: action };
446
+ return null;
447
+ }
448
+ /** The bounded outcome a command reports to presence: verdicts, never documents. */
449
+ export function presenceResult(result) {
450
+ const value = result && typeof result === "object" ? result : {};
451
+ const inner = value.result && typeof value.result === "object" ? value.result : {};
452
+ const outcome = { ok: value.ok !== false };
453
+ for (const [key, item] of Object.entries({
454
+ valid: inner.valid, compiled: inner.compiled, certified: inner.certified,
455
+ draft_created: value.draft_created, evidence_mode: value.evidence_mode,
456
+ diagnostic_count: Array.isArray(inner.diagnostics) ? inner.diagnostics.length : undefined,
457
+ })) {
458
+ if (typeof item === "boolean" || typeof item === "number" || typeof item === "string")
459
+ outcome[key] = item;
460
+ }
461
+ return outcome;
462
+ }
463
+ /**
464
+ * Report where the Agent is working in its local, unsaved source: changed
465
+ * JSON Pointers against the saved head and a working hash. Never the source
466
+ * itself. `--once` reports one settled state; otherwise it follows edits
467
+ * with a debounce until interrupted.
468
+ */
469
+ async function watchSource(parsed, subject, command, sourcePath, readLocal, readRemote, report) {
470
+ const remote = await readRemote();
471
+ const interval = Math.max(250, Math.min(5_000, Number(flag(parsed, "interval", "750"))));
472
+ let lastActivityAt = 0;
473
+ let last;
474
+ const settle = async (forceActivity) => {
475
+ let local;
476
+ try {
477
+ local = await readLocal();
478
+ }
479
+ catch {
480
+ await setPresence({ subject, status: "editing" });
481
+ return undefined;
482
+ }
483
+ const hash = sourceHash(local);
484
+ const paths = changedPaths(remote, local);
485
+ if (last && last.hash === hash && !forceActivity)
486
+ return last;
487
+ last = { hash, paths };
488
+ await setPresence({ subject, status: "editing", working_source_hash: hash, changed_paths: paths,
489
+ ...(paths[0] ? { focused_path: paths[0] } : {}) });
490
+ // The activity feed keeps a bounded history; presence keeps only now.
491
+ if (forceActivity || Date.now() - lastActivityAt >= 10_000) {
492
+ lastActivityAt = Date.now();
493
+ await report({ status: "completed", working_source_hash: hash, changed_paths: paths, change_count: paths.length }).catch(() => undefined);
494
+ }
495
+ return last;
496
+ };
497
+ if (parsed.flags.has("once")) {
498
+ const settled = await settle(true);
499
+ return { ok: true, command, subject, working_source_hash: settled?.hash || null, changed_paths: settled?.paths || [], parse_error: !settled };
500
+ }
501
+ await settle(true);
502
+ let timer;
503
+ const watcher = watchFile(path.dirname(sourcePath), (_event, name) => {
504
+ if (name && path.basename(String(name)) !== path.basename(sourcePath))
505
+ return;
506
+ clearTimeout(timer);
507
+ timer = setTimeout(() => { void settle(false); }, interval);
508
+ });
509
+ // Presence expires on the server; a quiet editor still says it is here.
510
+ const heartbeat = setInterval(() => { void settle(true).catch(() => undefined); }, 30_000);
511
+ process.stderr.write(`Watching ${sourcePath}; press Ctrl+C to stop.\n`);
512
+ await new Promise((resolve) => {
513
+ const stop = () => resolve();
514
+ process.once("SIGINT", stop);
515
+ process.once("SIGTERM", stop);
516
+ });
517
+ watcher.close();
518
+ clearInterval(heartbeat);
519
+ clearTimeout(timer);
520
+ await setPresence({ subject, status: "idle" });
521
+ return { ok: true, command, subject, working_source_hash: last?.hash || null, changed_paths: last?.paths || [] };
522
+ }
410
523
  async function capture(parsed, asset, inspect, activityOperation = inspect ? "browser.inspect" : "browser.capture") {
411
524
  const { source, hash } = await assertBrowserSource(parsed, asset);
412
525
  const view = flag(parsed, "view", "canvas");
@@ -570,7 +683,8 @@ async function libraryCheckout(parsed, kind, stableId) {
570
683
  checked_out_at: now,
571
684
  };
572
685
  await target.write(document, lock);
573
- return { ok: true, command: `${kind}.checkout`, kind, stable_id: stableId, source: target.sourcePath, lock: target.lockPath, source_hash: hash, head_revision: remote.head_revision || null, approved_revision: remote.approved_revision || null, schema_version: lock.schema_version };
686
+ const notice = kind === "experience" ? experienceSchemaNotice(document, serverContext) : null;
687
+ return { ok: true, command: `${kind}.checkout`, kind, stable_id: stableId, source: target.sourcePath, lock: target.lockPath, source_hash: hash, head_revision: remote.head_revision || null, approved_revision: remote.approved_revision || null, schema_version: lock.schema_version, ...(notice ? { schema_notice: notice } : {}) };
574
688
  }
575
689
  async function libraryStatus(parsed, kind, stableId) {
576
690
  const target = libraryWorkspace(parsed, kind, stableId);
@@ -595,6 +709,7 @@ async function libraryStatus(parsed, kind, stableId) {
595
709
  approved_revision: remote.approved_revision || null,
596
710
  schema_version: remote.schema_version ?? null,
597
711
  schema_changed: (remote.schema_version ?? null) !== lock.schema_version,
712
+ ...(kind === "experience" && experienceSchemaNotice(source, serverContext) ? { schema_notice: experienceSchemaNotice(source, serverContext) } : {}),
598
713
  };
599
714
  }
600
715
  async function librarySave(parsed, kind, stableId) {
@@ -638,9 +753,10 @@ async function libraryPreview(parsed, kind, stableId) {
638
753
  await writeFile(target.previewPath, `${JSON.stringify(preview, null, 2)}\n`, "utf8");
639
754
  const count = (value) => Array.isArray(value) ? value.length : value !== null && typeof value === "object" ? Object.keys(value).length : null;
640
755
  const summary = kind === "experience"
641
- ? { mode: preview.mode ?? null, source_hash: preview.sourceHash ?? null, scenes: count(preview.scenes), audio: count(preview.audio) }
756
+ ? experiencePreviewSummary(preview)
642
757
  : { id: preview.id ?? null, implementation: preview.implementation ?? null, outputs: count(preview.outputs) };
643
- return { ok: true, command: `${kind}.preview`, kind, stable_id: stableId, local_source_hash: sourceHash(source), preview: target.previewPath, summary };
758
+ const notice = kind === "experience" ? experienceSchemaNotice(source, serverContext) : null;
759
+ return { ok: true, command: `${kind}.preview`, kind, stable_id: stableId, local_source_hash: sourceHash(source), preview: target.previewPath, summary, ...(notice ? { schema_notice: notice } : {}) };
644
760
  }
645
761
  async function libraryExport(parsed, kind, stableId) {
646
762
  const revision = required(flag(parsed, "revision"), "--revision is required.");
@@ -685,6 +801,110 @@ async function audioUpload(file) {
685
801
  next: `nerdbro audio create <stable-id> --name <text> --media ${media.sha256}`,
686
802
  };
687
803
  }
804
+ async function readRecipe(parsed) {
805
+ const recipeFile = path.resolve(required(flag(parsed, "recipe"), "--recipe <recipe.json> is required."));
806
+ if ((await stat(recipeFile)).size > 65536)
807
+ throw new CliError("Recipe exceeds 64 KiB.", "VIDEO_RECIPE_INVALID", 2);
808
+ try {
809
+ return JSON.parse(await readFile(recipeFile, "utf8"));
810
+ }
811
+ catch {
812
+ throw new CliError("Recipe is not valid JSON.", "VIDEO_RECIPE_INVALID", 2);
813
+ }
814
+ }
815
+ /**
816
+ * Mix, encode, verify and describe one render. The audio is mixed from the
817
+ * canonical bytes the runtime would have played, at the times it logged;
818
+ * the MP4 is probed against its recipe; `render.json` says what it is a
819
+ * derivative of and how it was made.
820
+ */
821
+ async function finishRender(input) {
822
+ const { encoder, recipe, destination, frames } = input;
823
+ const durationMs = Math.round((frames.frames / recipe.fps) * 1000);
824
+ const log = (frames.audio?.events ?? []);
825
+ const sources = (frames.audio?.sources ?? {});
826
+ const mixStarted = Date.now();
827
+ let mixed;
828
+ let mixArgs = null;
829
+ // Logged times are video times from 0; the MP4 starts at `start`.
830
+ const startMs = Math.round(recipe.start * 1000);
831
+ const segments = recipe.audio === "scene" ? audioSegments(log, startMs + durationMs, startMs) : [];
832
+ if (segments.length) {
833
+ mixed = path.join(destination, "audio-mix.wav");
834
+ mixArgs = await mix(encoder, segments, new Map(Object.entries(sources).map(([key, value]) => [key, value.file])), durationMs, mixed);
835
+ }
836
+ const encodeStarted = Date.now();
837
+ 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);
840
+ verifyProbe(recipe, probed);
841
+ const level = await audioLevel(encoder, output);
842
+ if (segments.length && (level.max_db === null || level.max_db < -60)) {
843
+ throw new CliError("Sounds were logged but the encoded audio track is silent.", "VIDEO_AUDIO_SILENT", 1, { segments: segments.length });
844
+ }
845
+ const bytes = await readFile(output);
846
+ const manifest = {
847
+ kind: RENDER_KIND,
848
+ subject: input.subject,
849
+ derivative: true,
850
+ canonical: false,
851
+ certification_evidence: false,
852
+ note: input.note,
853
+ ...input.identity,
854
+ recipe,
855
+ recipe_sha256: sha256(JSON.stringify(recipe)),
856
+ scenario_sha256: sha256(JSON.stringify(recipe.actions)),
857
+ timing: {
858
+ clock: input.clock,
859
+ fps: recipe.fps,
860
+ start_seconds: recipe.start,
861
+ end_seconds: recipe.end,
862
+ frame_count: frames.frames,
863
+ frame_time: "start + k / fps (computed exactly, never accumulated); frame k exists iff start + k / fps < end",
864
+ first_frame_seconds: recipe.start,
865
+ last_frame_seconds: recipe.start + (frames.frames - 1) / recipe.fps,
866
+ },
867
+ presentation: {
868
+ host: "studio_present=1 (the learner runtime alone, no Studio chrome)",
869
+ profile: recipe.profile,
870
+ frame_size: frames.frame_size,
871
+ scaled_after_capture: false,
872
+ },
873
+ learner_surface: frames.learner_surface,
874
+ camera_shots: frames.camera_shots,
875
+ actions: frames.actions,
876
+ ...(input.extra ?? {}),
877
+ frames_sha256: frames.frames_sha256,
878
+ audio: {
879
+ mode: recipe.audio,
880
+ codec: "aac",
881
+ method: "canonical assets mixed by FFmpeg at their logged video times; nothing was recorded from a speaker",
882
+ events: log,
883
+ sources: Object.fromEntries(Object.entries(sources).map(([key, value]) => [key, { sha256: value.sha256, bytes: value.bytes }])),
884
+ segments,
885
+ mix_arguments: mixArgs?.map((value) => value.startsWith(destination) ? path.relative(destination, value) : value) ?? null,
886
+ level_dbfs: level,
887
+ ...(segments.length ? {} : { note: recipe.audio === "silent" ? "The recipe asked for silence." : "Nothing sounded during the rendered span." }),
888
+ },
889
+ encoder: { name: "ffmpeg", version: encoder.version, video: "libx264 High, yuv420p, CFR", crf: recipe.crf, arguments: encodeArguments(recipe, "frames/%06d.png", "video.mp4", mixed ? "audio-mix.wav" : undefined) },
890
+ output: { file: "video.mp4", bytes: bytes.length, sha256: sha256(bytes), probe: probed },
891
+ determinism: "Video times, learner actions and sound placements are exact. Pixels depend on the browser and GPU; compare renders by frame_sha256.",
892
+ performance: { ...(frames.playback_timing ? { playback_timing: frames.playback_timing } : {}),
893
+ capture_seconds: frames.capture_seconds, mix_seconds: (encodeStarted - mixStarted) / 1000,
894
+ encode_seconds: (Date.now() - encodeStarted) / 1000,
895
+ total_seconds: (Date.now() - input.started) / 1000, frames_per_second_captured: frames.frames / Math.max(frames.capture_seconds, 0.001),
896
+ agent_rss_mb: Math.round(process.memoryUsage().rss / 1048576) },
897
+ rendered_at: new Date().toISOString(),
898
+ };
899
+ await writeFile(path.join(destination, "render.json"), `${JSON.stringify({ ...manifest, frame_sha256: frames.frame_sha256 }, null, 2)}\n`);
900
+ if (!input.keepFrames)
901
+ await rm(path.join(destination, "frames"), { recursive: true, force: true });
902
+ if (mixed)
903
+ await rm(mixed, { force: true });
904
+ return { output, manifest: path.join(destination, "render.json"), frames: frames.frames,
905
+ width: probed.width, height: probed.height, duration_seconds: probed.duration, audio_segments: segments.length,
906
+ audio_max_dbfs: level.max_db, sha256: manifest.output.sha256 };
907
+ }
688
908
  async function libraryInspectBrowser(parsed, kind, stableId) {
689
909
  const target = libraryWorkspace(parsed, kind, stableId);
690
910
  const viewport = flag(parsed, "viewport", "desktop");
@@ -728,6 +948,144 @@ async function libraryInspectBrowser(parsed, kind, stableId) {
728
948
  return { ok: true, command: `${kind}.inspect-browser`, kind, stable_id: stableId, screenshot: target.screenshotPath, browser: browserPath, activity, activity_scope: "local", warning };
729
949
  }
730
950
  async function library(parsed, kind, action, argument) {
951
+ if (kind === "experience" && action === "drive") {
952
+ const stableId = required(argument, "Experience stable id is required.");
953
+ const target = libraryWorkspace(parsed, kind, stableId);
954
+ const scenarioFile = path.resolve(required(flag(parsed, "scenario"), "--scenario <file.json> is required."));
955
+ if ((await stat(scenarioFile)).size > 65536)
956
+ throw new CliError("Scenario exceeds 64 KiB.", "STUDIO_SCENARIO_INVALID", 2);
957
+ let document;
958
+ try {
959
+ document = JSON.parse(await readFile(scenarioFile, "utf8"));
960
+ }
961
+ catch {
962
+ throw new CliError("Scenario is not valid JSON.", "STUDIO_SCENARIO_INVALID", 2);
963
+ }
964
+ const scenario = parseExperienceScenario(document);
965
+ await ensureCompatibility();
966
+ const destination = path.resolve(flag(parsed, "out", `experience-drive-${stableId}-${Date.now()}`));
967
+ await mkdir(destination, { recursive: true });
968
+ // Drive the exact local source.json by default: an unsaved candidate is
969
+ // mounted in the Agent's own browser through the non-persisting Draft
970
+ // Preview and bound by its canonical hash. Nothing is saved, and nothing
971
+ // reaches learners. --saved drives the saved server revision instead.
972
+ let candidate;
973
+ let candidateHash;
974
+ let expectedRevision;
975
+ let expectedHash;
976
+ if (!parsed.flags.has("saved")) {
977
+ try {
978
+ candidate = await target.source();
979
+ candidateHash = sourceHash(candidate);
980
+ }
981
+ catch { /* No local checkout: drive the saved revision. */ }
982
+ }
983
+ if (candidateHash === undefined) {
984
+ try {
985
+ const lock = await target.lock();
986
+ expectedRevision = lock.head_revision_public_id || undefined;
987
+ expectedHash = lock.source_hash;
988
+ }
989
+ catch { /* An approved Experience can be driven without local checkout. */ }
990
+ }
991
+ const sourceOrigin = candidateHash ? "local" : "saved";
992
+ const scenarioHash = createHash("sha256").update(JSON.stringify(scenario)).digest("hex");
993
+ const driveMetadata = { scenario_hash: scenarioHash, step_count: scenario.steps.length,
994
+ viewport: flag(parsed, "viewport", "desktop"), source_origin: sourceOrigin,
995
+ ...(candidateHash ?? expectedHash ? { source_hash: candidateHash ?? expectedHash } : {}) };
996
+ let response;
997
+ try {
998
+ response = await daemonRpc("/browser/experience-drive", {
999
+ method: "POST",
1000
+ body: JSON.stringify({ asset: stableId, sourceHash: "", out: path.join(destination, "initial.png"),
1001
+ viewport: flag(parsed, "viewport", "desktop"), timeoutMs: Number(flag(parsed, "timeout", "30000")),
1002
+ library: { workspace: "experiences", selection: stableId, revision: expectedRevision, definitionHash: expectedHash }, scenario,
1003
+ ...(candidateHash ? { candidate, candidateHash } : {}),
1004
+ presenceSubject: `experience:${stableId}` }),
1005
+ });
1006
+ }
1007
+ catch (error) {
1008
+ const failure = error instanceof CliError ? error : new CliError(String(error));
1009
+ const summaries = failure.details?.step_summaries;
1010
+ await reportExperienceActivity(stableId, "experience.drive", { ...driveMetadata, status: "failed", error_code: failure.code,
1011
+ ...(Array.isArray(summaries) ? { steps: summaries.slice(0, 32), failed_step_index: summaries.length - 1 } : {}) }).catch(() => undefined);
1012
+ throw error;
1013
+ }
1014
+ const resultPath = path.join(destination, "result.json");
1015
+ await writeFile(resultPath, `${JSON.stringify(response.result, null, 2)}\n`);
1016
+ const evidenceMode = response.result.evidence_mode === "journey" ? "journey" : "probe";
1017
+ await reportExperienceActivity(stableId, "experience.drive", { ...driveMetadata, status: "completed", evidence_mode: evidenceMode,
1018
+ steps: response.result.step_summaries?.slice(0, 32) }).catch(() => undefined);
1019
+ return { ok: true, command: "experience.drive", stable_id: stableId, output: destination,
1020
+ result: resultPath, steps: scenario.steps.length, evidence_mode: evidenceMode,
1021
+ source_origin: sourceOrigin, ...(candidateHash ? { source_hash: candidateHash } : {}),
1022
+ ...(evidenceMode === "probe" ? { evidence_note: "Probe evidence: the run jumped, fired, seeked or used synthetic setup. It does not prove a learner can reach this point; prove that with a journey from the initial State." } : {}) };
1023
+ }
1024
+ if (kind === "experience" && action === "render-video") {
1025
+ // A presentation derivative of an Experience: see video.ts. The real
1026
+ // Experience player, in the presentation host, driven by video time.
1027
+ const stableId = required(argument, "Experience stable id is required.");
1028
+ const target = libraryWorkspace(parsed, kind, stableId);
1029
+ const recipe = parseRecipe(await readRecipe(parsed), "experience");
1030
+ const encoder = await findEncoder();
1031
+ 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
+ }
1053
+ 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
+ });
1082
+ return { ok: true, command: "experience.render-video", stable_id: stableId, ...result };
1083
+ }
1084
+ if (kind === "experience" && action === "watch") {
1085
+ const stableId = required(argument, "Experience stable id is required.");
1086
+ const target = libraryWorkspace(parsed, kind, stableId);
1087
+ return watchSource(parsed, `experience:${stableId}`, "experience.watch", target.sourcePath, () => target.source(), async () => (await api("GET", `${authoringRoute(kind, stableId)}?purpose=inspect`)).document, (metadata) => reportExperienceActivity(stableId, "experience.source.edit", metadata));
1088
+ }
731
1089
  if (kind === "audio" && action === "upload")
732
1090
  return audioUpload(required(argument, "Audio file is required."));
733
1091
  if (AUTHORING_SEARCH_KINDS.includes(kind) && action === "search") {
@@ -822,10 +1180,23 @@ async function run(parsed) {
822
1180
  return daemonRpc("/disconnect", { method: "POST", body: "{}" });
823
1181
  if (first === "whoami")
824
1182
  return { ok: true, command: "whoami", result: await api("GET", "/v1/agent/whoami") };
825
- if (first === "context" || first === "capabilities")
826
- return { ok: true, command: first, result: await api("GET", `/v1/agent/${first}`) };
827
- if (first === "schema")
828
- return { ok: true, command: "schema", result: await api("GET", second ? `/v1/agent/schema/${encodeURIComponent(second)}` : "/v1/agent/schema") };
1183
+ if (first === "context") {
1184
+ const result = await api("GET", "/v1/agent/context");
1185
+ return { ok: true, command: "context", summary: contextSummary(result, VERSION, AGENT_PROTOCOL), result };
1186
+ }
1187
+ if (first === "capabilities") {
1188
+ const registry = await api("GET", "/v1/agent/capabilities");
1189
+ return { ok: true, command: "capabilities", ...capabilitiesView(registry, await liveContext()) };
1190
+ }
1191
+ if (first === "schema") {
1192
+ const result = await api("GET", second ? `/v1/agent/schema/${encodeURIComponent(second)}` : "/v1/agent/schema");
1193
+ if (!second)
1194
+ return { ok: true, command: "schema", result };
1195
+ const view = schemaTopicView(second, result, await liveContext());
1196
+ if (typeof view.deprecated === "string" && !parsed.json)
1197
+ process.stderr.write(`nerdbro: schema ${second} is frozen: ${view.deprecated}\n`);
1198
+ return { ok: true, command: "schema", result: view };
1199
+ }
829
1200
  if (first === "guide") {
830
1201
  const result = await api("GET", "/v1/agent/docs/agent-authoring-playbook");
831
1202
  return {
@@ -889,6 +1260,17 @@ async function run(parsed) {
889
1260
  if (second === "inspect")
890
1261
  return { ok: true, command: `${first}.inspect`, result: await api("GET", `/v1/agent/catalogue/${plural}/${encodeURIComponent(required(third, "ID is required."))}`) };
891
1262
  }
1263
+ if (first === "world") {
1264
+ // Live World discovery; no World list is bundled with this package.
1265
+ if (!second || second === "list")
1266
+ return { ok: true, command: "world.list", result: await api("GET", "/v1/agent/worlds") };
1267
+ if (second === "inspect") {
1268
+ const world = required(third, "World ID is required.");
1269
+ const zone = flag(parsed, "zone");
1270
+ const query = zone ? `?zone=${encodeURIComponent(zone)}` : "";
1271
+ return { ok: true, command: "world.inspect", result: await api("GET", `/v1/agent/worlds/${encodeURIComponent(world)}${query}`) };
1272
+ }
1273
+ }
892
1274
  if (isLibraryKind(first)) {
893
1275
  const result = await library(parsed, first, second, third);
894
1276
  if (result !== undefined)
@@ -898,7 +1280,8 @@ async function run(parsed) {
898
1280
  const action = required(second, "Studio command is required.");
899
1281
  if (action === "create") {
900
1282
  const title = required(flag(parsed, "title"), "--title is required.");
901
- const target = required(flag(parsed, "target"), "--target is required.");
1283
+ // Default and frozen targets come from the live context, never from this package.
1284
+ const target = resolveCreateTarget(flag(parsed, "target"), await liveContext());
902
1285
  const concept = required(flag(parsed, "concept"), "--concept is required.");
903
1286
  const brief = {
904
1287
  concept,
@@ -910,21 +1293,26 @@ async function run(parsed) {
910
1293
  graph_y_semantics: flag(parsed, "graph-y"),
911
1294
  graph_sync_semantics: flag(parsed, "graph-sync"),
912
1295
  language: flag(parsed, "language", "bilingual"),
913
- requires_graph: target === "combined" || Boolean(flag(parsed, "graph-purpose")),
1296
+ // A graph is a Scene math plane, asked for by graph intent, not by a target.
1297
+ requires_graph: mathPlaneRequirement(parsed.flags),
914
1298
  requires_assessment: parsed.flags.has("assessment"),
915
1299
  additional_instruction: flag(parsed, "additional-instruction"),
916
1300
  selected_components: flag(parsed, "components")?.split(",").map((value) => value.trim()).filter(Boolean),
917
1301
  selected_behavior: flag(parsed, "behavior"),
918
1302
  important_values: flag(parsed, "important-values")?.split(",").map((value) => value.trim()).filter(Boolean),
919
1303
  };
1304
+ // A World zone starter (world:<world>/<zone>) is resolved by the server
1305
+ // from the live World registry: the same Scene the human Playground opens.
1306
+ const starter = flag(parsed, "starter");
920
1307
  const result = await api("POST", "/v1/agent/studio", {
921
1308
  client_command_id: flag(parsed, "command-id", randomUUID()),
922
1309
  title, authoring_target: target, brief,
1310
+ ...(starter ? { starter } : {}),
923
1311
  });
924
1312
  // A newly created Studio asset has no approved revision or draft; it is
925
1313
  // already the isolated scratch surface. The flag makes that intent
926
1314
  // explicit without inventing a second server-side lifecycle.
927
- return { ok: true, command: "studio.create", scratch: parsed.flags.has("scratch"), result };
1315
+ return { ok: true, command: "studio.create", scratch: parsed.flags.has("scratch"), target, math_plane_requested: brief.requires_graph === true, result };
928
1316
  }
929
1317
  if (action === "brief") {
930
1318
  const updating = third === "update";
@@ -951,8 +1339,9 @@ async function run(parsed) {
951
1339
  brief.requires_assessment = true;
952
1340
  if (parsed.flags.has("no-assessment"))
953
1341
  brief.requires_assessment = false;
954
- if (flag(parsed, "graph-purpose"))
955
- brief.requires_graph = true;
1342
+ const mathPlane = mathPlaneRequirement(parsed.flags);
1343
+ if (mathPlane !== undefined)
1344
+ brief.requires_graph = mathPlane;
956
1345
  if (flag(parsed, "components"))
957
1346
  brief.selected_components = flag(parsed, "components").split(",").map((value) => value.trim()).filter(Boolean);
958
1347
  if (flag(parsed, "important-values"))
@@ -966,6 +1355,9 @@ async function run(parsed) {
966
1355
  return checkout(parsed, asset);
967
1356
  if (action === "status")
968
1357
  return studioStatus(parsed, asset);
1358
+ if (action === "watch") {
1359
+ return watchSource(parsed, `asset:${asset}`, "studio.watch", target.sourcePath, () => target.source(), async () => (await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}`)).source, (metadata) => reportBrowserActivity(asset, "studio.source.edit", metadata));
1360
+ }
969
1361
  if (action === "source")
970
1362
  return { ok: true, command: "studio.source", asset, source: await target.source() };
971
1363
  if (["validate", "compile", "preview"].includes(action))
@@ -1050,19 +1442,57 @@ async function run(parsed) {
1050
1442
  response = await daemonRpc("/browser/drive", {
1051
1443
  method: "POST",
1052
1444
  body: JSON.stringify({ asset, sourceHash: remoteHash, candidate, candidateHash: hash, scenario,
1445
+ presenceSubject: `asset:${asset}`,
1053
1446
  viewport: flag(parsed, "viewport", "desktop"),
1054
1447
  out: path.join(destination, "initial.png"), timeoutMs: Number(flag(parsed, "timeout", "30000")) }),
1055
1448
  });
1056
1449
  }
1057
1450
  catch (error) {
1058
1451
  const failure = error instanceof CliError ? error : new CliError(String(error));
1059
- await reportBrowserActivity(asset, "studio.drive", { ...driveMetadata, status: "failed", error_code: failure.code }).catch(() => undefined);
1452
+ const summaries = failure.details?.step_summaries;
1453
+ await reportBrowserActivity(asset, "studio.drive", { ...driveMetadata, status: "failed", error_code: failure.code,
1454
+ ...(Array.isArray(summaries) ? { steps: summaries.slice(0, 32), failed_step_index: summaries.length - 1 } : {}) }).catch(() => undefined);
1060
1455
  throw error;
1061
1456
  }
1062
1457
  await writeFile(path.join(destination, "result.json"), `${JSON.stringify(response.result, null, 2)}\n`);
1063
- await reportBrowserActivity(asset, "studio.drive", { ...driveMetadata, status: "completed" });
1458
+ const evidenceMode = response.result.evidence_mode === "journey" ? "journey" : "probe";
1459
+ await reportBrowserActivity(asset, "studio.drive", { ...driveMetadata, status: "completed", evidence_mode: evidenceMode,
1460
+ steps: response.result.step_summaries?.slice(0, 32) });
1064
1461
  return { ok: true, command: "studio.drive", asset, source_hash: hash, output: destination,
1065
- result: path.join(destination, "result.json"), steps: scenario.steps.length };
1462
+ result: path.join(destination, "result.json"), steps: scenario.steps.length, evidence_mode: evidenceMode,
1463
+ ...(evidenceMode === "probe" ? { evidence_note: "Probe evidence: the run jumped, fired, seeked or used synthetic setup. It does not prove a learner can reach this point; prove that with a journey from the initial State." } : {}) };
1464
+ }
1465
+ if (action === "render-video") {
1466
+ // A presentation derivative, never content or evidence: see video.ts.
1467
+ const recipe = parseRecipe(await readRecipe(parsed), "scene");
1468
+ // Refuse before any browser work: no encoder, no render.
1469
+ const encoder = await findEncoder();
1470
+ const candidate = await target.source();
1471
+ const hash = sourceHash(candidate);
1472
+ const remote = await studioStatus(parsed, asset);
1473
+ const remoteHash = String(remote.remote_source_hash || "");
1474
+ if (!remoteHash && remote.remote_snapshot_public_id) {
1475
+ throw new CliError("Studio has a draft without a readable source.", "STUDIO_SOURCE_UNAVAILABLE", 3);
1476
+ }
1477
+ 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
+ });
1495
+ return { ok: true, command: "studio.render-video", asset, source_hash: hash, ...result };
1066
1496
  }
1067
1497
  if (action === "capture-preview") {
1068
1498
  const { hash } = await assertBrowserSource(parsed, asset);
@@ -1118,24 +1548,39 @@ function help() {
1118
1548
  return [
1119
1549
  `NerdBro Studio Agent ${VERSION}`,
1120
1550
  "",
1121
- "Commands: setup, connect, disconnect, whoami, context, guide, schema [topic], capabilities",
1551
+ "Model: everything visual is a Scene (graphs are Scene math planes); interaction is an Experience.",
1552
+ "",
1553
+ "Commands: setup, connect, disconnect, whoami, guide",
1554
+ " context live contract versions, offered/frozen targets, deprecations, compatibility",
1555
+ " schema [topic] topic index, or one live projection (e.g. experience, scene.stateMachines)",
1556
+ " capabilities [--json] universal Experience capabilities and tools, plus the Studio capability registry",
1122
1557
  " docs | docs search <query> | docs open <slug>",
1123
1558
  " agent start|status|stop|instructions|init --provider claude|codex|generic",
1124
1559
  " component|behavior|example search <query>|inspect <id>",
1125
1560
  " studio checkout|status|source|history|validate|compile|preview|save <asset>",
1126
- " studio create --scratch --title <text> --target <target> --concept <text> [--command-id <id>]",
1127
- " studio brief <asset> | studio brief update <asset> [--concept <text> ...]",
1561
+ " studio create --scratch --title <text> --concept <text> [--target sceneComposition] [--starter world:<world>/<zone>] [--command-id <id>]",
1562
+ " --target defaults to sceneComposition; targets the server lists as frozen (combined, mathGraph) are refused",
1563
+ " graph intent: --math-plane | --graph-purpose <text> [--graph-x <text>] [--graph-y <text>] [--graph-sync <text>]",
1564
+ " world list | world inspect <world> [--zone <zone>] live World discovery and zone starter Source",
1565
+ " studio brief <asset> | studio brief update <asset> [--concept <text> ...] [--math-plane | --no-math-plane]",
1128
1566
  " studio open|screenshot|inspect|observe|capture-preview|certify <asset>",
1129
1567
  " studio drive <asset> --scenario scenario.json [--workspace dir] [--viewport desktop|mobile] [--out dir]",
1568
+ " steps include previewState \"machine/state\" [+ setup], select \"kind:id\", restart; result says probe or journey",
1569
+ " studio watch <asset> [--once] [--interval ms] report changed source paths to the Maker's Studio (Agent Presence)",
1130
1570
  " studio inspect <asset> --view final|learner --viewport desktop|mobile",
1131
1571
  " studio capture-preview <asset> [--mode semantic|fixed] [--viewport 480x320] [--duration 8] [--fps 4] [--out dir]",
1572
+ " studio render-video <asset> --recipe recipe.json [--out dir] [--keep-frames] [--measure-playback 15]",
1573
+ " presentation MP4 (H.264/AAC) of the learner surface at exact SceneClock times; needs local FFmpeg; writes render.json",
1132
1574
  " studio export <asset> --revision <revision> [--out file.kspkg]",
1133
1575
  "",
1134
1576
  "Library authoring (<kind> = experience|audio|component|behavior):",
1135
1577
  " experience|audio search <query> [--limit n] | inspect <stable-id>",
1136
1578
  " audio upload <file.m4a|file.mp4> AAC in MP4/M4A, at most 24 MiB",
1137
1579
  " audio create <stable-id> --name <text> --media <sha256> [--category c] [--language l]",
1138
- " experience create <stable-id> --name <text> --kind <poem|story|...>",
1580
+ " experience create <stable-id> --name <text> --kind <poem|story|...> (authored as the context's experience_schema, V3)",
1581
+ " experience drive <stable-id> --scenario scenario.json [--viewport desktop|mobile] [--out dir]",
1582
+ " experience render-video <stable-id> --recipe recipe.json [--saved] [--out dir] [--keep-frames]",
1583
+ " experience watch <stable-id> [--once] [--interval ms]",
1139
1584
  " (--scene-asset <asset-id> | --scene-package <kind:id> --scene <scene-id>)",
1140
1585
  " [--master-audio <audio-id>] [--duration-ms <n>]",
1141
1586
  " component create <stable-id> --name <text> [--category c] [--visual v] [--palette p]",
@@ -1165,7 +1610,22 @@ async function main() {
1165
1610
  commandId = `cmd_${randomUUID().replaceAll("-", "")}`;
1166
1611
  setCliTrace({ attemptId: active?.pointer.attempt_id, commandId });
1167
1612
  }
1168
- const result = await run(parsed);
1613
+ const plan = presencePlan(parsed.args);
1614
+ if (plan)
1615
+ await setPresence({ subject: plan.subject, status: plan.status, last_command: { op: plan.op } });
1616
+ let result;
1617
+ try {
1618
+ result = await run(parsed);
1619
+ }
1620
+ catch (error) {
1621
+ if (plan) {
1622
+ const code = error instanceof CliError ? error.code : "AGENT_COMMAND_FAILED";
1623
+ await setPresence({ subject: plan.subject, status: "idle", last_command: { op: plan.op }, last_result: { ok: false, code } });
1624
+ }
1625
+ throw error;
1626
+ }
1627
+ if (plan)
1628
+ await setPresence({ subject: plan.subject, status: "idle", last_command: { op: plan.op }, last_result: presenceResult(result) });
1169
1629
  if (active && commandId && parsed.args[0] !== "attempt")
1170
1630
  await saveCommandEvidence(active.pointer, commandId, parsed.args.slice(0, 2).join("."), result);
1171
1631
  output(result, parsed.json);