motionloom 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import { spawnSync } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+ const expectedVersion = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version;
11
+ const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "motionloom-consumer-"));
12
+ const consumer = path.join(temporary, "consumer");
13
+ fs.mkdirSync(consumer);
14
+ fs.writeFileSync(
15
+ path.join(consumer, "package.json"),
16
+ `${JSON.stringify({ name: "motionloom-package-smoke", private: true }, null, 2)}\n`,
17
+ );
18
+
19
+ function run(command, args, options = {}) {
20
+ const result = spawnSync(command, args, {
21
+ cwd: options.cwd || root,
22
+ encoding: "utf8",
23
+ env: process.env,
24
+ ...options,
25
+ });
26
+ if (result.status !== 0) {
27
+ throw new Error(
28
+ `${command} ${args.join(" ")} failed (${result.status})\n${result.stdout || ""}\n${result.stderr || ""}`,
29
+ );
30
+ }
31
+ return result.stdout.trim();
32
+ }
33
+
34
+ try {
35
+ const packOutput = JSON.parse(run("npm", ["pack", "--json", "--pack-destination", temporary]));
36
+ const tarball = path.join(temporary, packOutput[0].filename);
37
+ run("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", tarball], { cwd: consumer });
38
+
39
+ const bin = process.platform === "win32"
40
+ ? path.join(consumer, "node_modules", ".bin", "motionloom.cmd")
41
+ : path.join(consumer, "node_modules", ".bin", "motionloom");
42
+ const installedRoot = path.dirname(fs.realpathSync(path.join(consumer, "node_modules", "motionloom", "package.json")));
43
+ const help = run(bin, ["--help"], { cwd: consumer });
44
+ const init = JSON.parse(run(bin, ["init", "--dry-run", "--json"], { cwd: consumer }));
45
+ const doctor = JSON.parse(run(bin, ["doctor", "--json"], { cwd: consumer }));
46
+
47
+ if (!help.startsWith(`MotionLoom ${expectedVersion}`)) throw new Error(`unexpected help version: ${help.split("\n")[0]}`);
48
+ if (fs.realpathSync(init.project_root || ".") !== fs.realpathSync(consumer)) {
49
+ throw new Error(`installed CLI used the wrong project_root: ${init.project_root}`);
50
+ }
51
+ if (doctor.status !== "pass") throw new Error(`installed doctor failed: ${JSON.stringify(doctor.errors)}`);
52
+ for (const relative of [
53
+ "requirements.txt",
54
+ "dev-lab/public/index.html",
55
+ "dev-lab/public/devlab.js",
56
+ "tests/runtime-harness/index.html",
57
+ ]) {
58
+ if (!fs.existsSync(path.join(installedRoot, relative))) throw new Error(`tarball omitted ${relative}`);
59
+ }
60
+ const scene = "browser-review-smoke";
61
+ const consumerScene = path.join(consumer, "src", "output", scene);
62
+ fs.mkdirSync(path.dirname(consumerScene), { recursive: true });
63
+ fs.cpSync(path.join(installedRoot, "src", "output", scene), consumerScene, { recursive: true });
64
+ const candidatePath = path.join(consumerScene, "browser-review.json");
65
+ const candidate = JSON.parse(fs.readFileSync(candidatePath, "utf8"));
66
+ candidate.expires_at = "2099-01-01T00:00:00Z";
67
+ fs.writeFileSync(candidatePath, `${JSON.stringify(candidate, null, 2)}\n`);
68
+ run(bin, ["devlab", scene, "--prepare-only"], { cwd: consumer });
69
+ if (!fs.existsSync(path.join(installedRoot, "dev-lab", "public", "scenes", scene, "browser-review.json"))) {
70
+ throw new Error("installed Dev Lab did not prepare the consumer scene");
71
+ }
72
+ run(process.execPath, ["--input-type=module", "-e", "await import('playwright'); await import('vite');"], { cwd: consumer });
73
+ console.log(JSON.stringify({ status: "pass", project_root: init.project_root, installed_root: installedRoot }, null, 2));
74
+ } finally {
75
+ fs.rmSync(temporary, { recursive: true, force: true });
76
+ }
@@ -16,7 +16,10 @@ from urllib.parse import urlencode, urlsplit, urlunsplit
16
16
 
17
17
  ROOT = Path(__file__).resolve().parents[1]
18
18
  SAFE_SCENE = re.compile(r"^[A-Za-z0-9._-]+$")
19
+ SAFE_ANIMATION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
19
20
  CANDIDATE_TTL = timedelta(hours=24)
21
+ RUNTIME_DESCRIPTOR = "devlab-runtime.json"
22
+ RUNTIME_MODES = {"sprite-sequence", "iframe"}
20
23
 
21
24
 
22
25
  def now() -> str:
@@ -37,18 +40,24 @@ def parse_time(value: str) -> datetime:
37
40
  return datetime.fromisoformat(value.replace("Z", "+00:00"))
38
41
 
39
42
 
40
- def candidate_id(task_id: str, scene: str, context_hash: str, source_hash: str, render_hash: str) -> str:
41
- raw = f"{task_id}:{scene}:{context_hash}:{source_hash}:{render_hash}".encode()
42
- return hashlib.sha256(raw).hexdigest()[:20]
43
+ def candidate_id(
44
+ task_id: str,
45
+ scene: str,
46
+ context_hash: str,
47
+ source_hash: str,
48
+ render_hash: str,
49
+ runtime_bundle_hash: str | None = None,
50
+ ) -> str:
51
+ # Keep the legacy identity formula byte-for-byte when a scene has no live
52
+ # runtime descriptor so already-prepared snapshot candidates remain valid.
53
+ raw = f"{task_id}:{scene}:{context_hash}:{source_hash}:{render_hash}"
54
+ if runtime_bundle_hash:
55
+ raw += f":devlab-runtime:{runtime_bundle_hash}"
56
+ return hashlib.sha256(raw.encode()).hexdigest()[:20]
43
57
 
44
58
 
45
59
  def review_urls(lab_url: str, scene: str, task_id: str, candidate: str) -> tuple[str, str, str]:
46
- """Build an exact review route while anchoring artifact paths at its origin.
47
-
48
- `--lab-url` is the actual browser route, e.g. `https://host/lab` for the
49
- React Dev Lab or `http://127.0.0.1:3300/` for the bundled static lab.
50
- Artifact and task paths intentionally remain same-origin root paths.
51
- """
60
+ """Build an exact review route while anchoring artifact paths at its origin."""
52
61
  parsed = urlsplit(lab_url.strip())
53
62
  if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.query or parsed.fragment:
54
63
  raise ValueError("lab-url must be an absolute http(s) Dev Lab route without query or fragment")
@@ -62,7 +71,11 @@ def review_urls(lab_url: str, scene: str, task_id: str, candidate: str) -> tuple
62
71
  "artifact_base": f"{origin}/scenes/{scene}",
63
72
  "task_base": f"{origin}/tasks/{task_id}",
64
73
  })
65
- return f"{review_base}/?{query}" if review_path != "/" else f"{origin}/?{query}", f"{origin}/scenes/{scene}", f"{origin}/tasks/{task_id}"
74
+ return (
75
+ f"{review_base}/?{query}" if review_path != "/" else f"{origin}/?{query}",
76
+ f"{origin}/scenes/{scene}",
77
+ f"{origin}/tasks/{task_id}",
78
+ )
66
79
 
67
80
 
68
81
  def paths(task_dir: Path, task: dict) -> tuple[Path, Path, Path, Path]:
@@ -84,6 +97,137 @@ def paths(task_dir: Path, task: dict) -> tuple[Path, Path, Path, Path]:
84
97
  return scene_dir, source_path, render_meta, context_path
85
98
 
86
99
 
100
+ def safe_relative_file(scene_dir: Path, value: object, label: str) -> tuple[str, Path]:
101
+ if not isinstance(value, str) or not value or value.startswith(("/", "\\")) or "\\" in value:
102
+ raise ValueError(f"{label} must be a scene-relative path")
103
+ parts = Path(value).parts
104
+ if not parts or any(part in {"", ".", ".."} for part in parts):
105
+ raise ValueError(f"{label} contains unsafe path segments: {value!r}")
106
+ scene_root = scene_dir.resolve()
107
+ current = scene_root
108
+ for part in parts:
109
+ current = current / part
110
+ if current.is_symlink():
111
+ raise ValueError(f"{label} must not traverse symlinks: {value}")
112
+ resolved = (scene_root / value).resolve()
113
+ if not resolved.is_file() or scene_root not in resolved.parents:
114
+ raise ValueError(f"{label} must resolve to an existing file inside the scene: {value}")
115
+ return value, resolved
116
+
117
+
118
+ def runtime_bundle(scene_dir: Path) -> dict | None:
119
+ descriptor_path = scene_dir / RUNTIME_DESCRIPTOR
120
+ if not descriptor_path.exists():
121
+ return None
122
+ if descriptor_path.is_symlink() or not descriptor_path.is_file():
123
+ raise ValueError("devlab-runtime.json must be a regular scene-local file")
124
+ descriptor = read_json(descriptor_path)
125
+ if descriptor.get("schema_version") != "1.0":
126
+ raise ValueError("devlab-runtime.json schema_version must be 1.0")
127
+ mode = descriptor.get("mode")
128
+ if mode not in RUNTIME_MODES:
129
+ raise ValueError(f"devlab-runtime.json mode is unsupported: {mode!r}")
130
+ files = descriptor.get("files")
131
+ if not isinstance(files, list) or not files:
132
+ raise ValueError("devlab-runtime.json requires a non-empty files array")
133
+ resolved_files: dict[str, Path] = {}
134
+ reserved_runtime_files = {RUNTIME_DESCRIPTOR, "browser-review.json", "review.json"}
135
+ for raw in files:
136
+ relative, resolved = safe_relative_file(scene_dir, raw, "runtime file")
137
+ if relative in reserved_runtime_files:
138
+ raise ValueError(f"devlab-runtime.json must not hash mutable review metadata as runtime bytes: {relative}")
139
+ if relative in resolved_files:
140
+ raise ValueError(f"devlab-runtime.json repeats runtime file: {relative}")
141
+ resolved_files[relative] = resolved
142
+
143
+ animations = descriptor.get("animations")
144
+ if not isinstance(animations, list) or not animations:
145
+ raise ValueError("devlab-runtime.json requires at least one animation")
146
+ animation_ids: list[str] = []
147
+ for animation in animations:
148
+ if not isinstance(animation, dict):
149
+ raise ValueError("devlab-runtime.json animations must be objects")
150
+ action_id = animation.get("id")
151
+ if not isinstance(action_id, str) or not SAFE_ANIMATION.fullmatch(action_id):
152
+ raise ValueError(f"devlab-runtime.json animation id is invalid: {action_id!r}")
153
+ if action_id in animation_ids:
154
+ raise ValueError(f"devlab-runtime.json repeats animation id: {action_id}")
155
+ animation_ids.append(action_id)
156
+ if mode == "sprite-sequence":
157
+ fps = animation.get("fps")
158
+ frames = animation.get("frames")
159
+ if not isinstance(fps, (int, float)) or isinstance(fps, bool) or fps <= 0:
160
+ raise ValueError(f"sprite animation {action_id} requires a positive fps")
161
+ if not isinstance(frames, list) or not frames:
162
+ raise ValueError(f"sprite animation {action_id} requires frames")
163
+ for frame in frames:
164
+ relative, _ = safe_relative_file(scene_dir, frame, f"frame for {action_id}")
165
+ if relative not in resolved_files:
166
+ raise ValueError(f"frame for {action_id} is not declared in runtime files: {relative}")
167
+
168
+ default_animation = descriptor.get("default_animation")
169
+ if default_animation not in animation_ids:
170
+ raise ValueError("devlab-runtime.json default_animation must reference a declared animation")
171
+ if mode == "iframe":
172
+ entrypoint, _ = safe_relative_file(scene_dir, descriptor.get("entrypoint"), "runtime entrypoint")
173
+ if entrypoint not in resolved_files:
174
+ raise ValueError("runtime entrypoint must be declared in runtime files")
175
+
176
+ controls = descriptor.get("controls")
177
+ if not isinstance(controls, dict):
178
+ raise ValueError("devlab-runtime.json controls object is required")
179
+ for control in ("play", "pause", "restart", "seek", "step", "speed", "loop"):
180
+ if not isinstance(controls.get(control), bool):
181
+ raise ValueError(f"devlab-runtime.json controls.{control} must be boolean")
182
+ review_policy = descriptor.get("review_policy")
183
+ if not isinstance(review_policy, dict) or not isinstance(review_policy.get("require_all_animations"), bool):
184
+ raise ValueError("devlab-runtime.json review_policy.require_all_animations must be boolean")
185
+
186
+ digest = hashlib.sha256()
187
+ digest.update(b"motionloom-devlab-runtime-v1\0")
188
+ digest.update(descriptor_path.read_bytes())
189
+ for relative in sorted(resolved_files):
190
+ digest.update(b"\0path\0")
191
+ digest.update(relative.encode("utf-8"))
192
+ digest.update(b"\0bytes\0")
193
+ digest.update(resolved_files[relative].read_bytes())
194
+ return {
195
+ "descriptor": descriptor,
196
+ "bundle_sha256": digest.hexdigest(),
197
+ "animations": animation_ids,
198
+ "mode": mode,
199
+ "files": sorted(resolved_files),
200
+ "review_policy": {"require_all_animations": review_policy["require_all_animations"]},
201
+ }
202
+
203
+
204
+ def runtime_review_payload(bundle: dict | None) -> dict:
205
+ if not bundle:
206
+ return {
207
+ "live": False,
208
+ "mode": "captured-evidence",
209
+ "checkpoints": [0, 50, 100],
210
+ }
211
+ return {
212
+ "live": True,
213
+ "mode": bundle["mode"],
214
+ "descriptor": RUNTIME_DESCRIPTOR,
215
+ "bundle_sha256": bundle["bundle_sha256"],
216
+ "animations": bundle["animations"],
217
+ "review_policy": bundle["review_policy"],
218
+ }
219
+
220
+
221
+ def review_instruction(bundle: dict | None) -> str:
222
+ if bundle:
223
+ actions = ", ".join(bundle["animations"])
224
+ return (
225
+ "Open this exact URL in the internal browser; exercise the live runtime controls, "
226
+ f"inspect the declared animations ({actions}), scrub/step the candidate, and ask the user to approve or request changes."
227
+ )
228
+ return "Open this exact URL in the internal browser; inspect frames 0/50/100 and ask the user to approve or request changes."
229
+
230
+
87
231
  def prepare(args: argparse.Namespace) -> int:
88
232
  task_dir = Path(args.task_dir).resolve()
89
233
  task_path = task_dir / "task.json"
@@ -92,6 +236,7 @@ def prepare(args: argparse.Namespace) -> int:
92
236
  if not scene:
93
237
  raise ValueError("task.json requires scene")
94
238
  scene_dir, source_path, render_meta, context_path = paths(task_dir, task)
239
+ bundle = runtime_bundle(scene_dir)
95
240
  memory_path = ROOT / ".motionloom" / "project-memory.json"
96
241
  memory = read_json(memory_path, {}) if memory_path.is_file() else {}
97
242
  memory_status = (memory.get("freshness") or {}).get("status")
@@ -101,7 +246,7 @@ def prepare(args: argparse.Namespace) -> int:
101
246
  context_hash = (spec.get("context_binding") or {}).get("context_sha256") or sha256(context_path)
102
247
  source_hash = sha256(source_path)
103
248
  render_hash = sha256(render_meta)
104
- cid = candidate_id(task["task_id"], scene, context_hash, source_hash, render_hash)
249
+ cid = candidate_id(task["task_id"], scene, context_hash, source_hash, render_hash, bundle["bundle_sha256"] if bundle else None)
105
250
  task_id = task["task_id"]
106
251
  url, artifact_base, task_base = review_urls(args.lab_url, scene, task_id, cid)
107
252
  candidate = {
@@ -115,6 +260,7 @@ def prepare(args: argparse.Namespace) -> int:
115
260
  "source_sha256": source_hash,
116
261
  "runtime": spec.get("framework", "unknown"),
117
262
  "checkpoints": [0, 50, 100],
263
+ "runtime_review": runtime_review_payload(bundle),
118
264
  "review_artifact": "review.json",
119
265
  "requires_user_approval": True,
120
266
  "prepared_at": now(),
@@ -126,27 +272,81 @@ def prepare(args: argparse.Namespace) -> int:
126
272
  (scene_dir / "browser-review.json").write_text(json.dumps(candidate, indent=2) + "\n", encoding="utf-8")
127
273
  task["state"] = "review_required"
128
274
  task["updated_at"] = now()
129
- task["browser_review"] = {"required": True, "status": "prepared", "candidate_id": cid, "candidate_path": "browser-review.json", "review_artifact": "review.json"}
275
+ task["browser_review"] = {
276
+ "required": True,
277
+ "status": "prepared",
278
+ "candidate_id": cid,
279
+ "candidate_path": "browser-review.json",
280
+ "review_artifact": "review.json",
281
+ "runtime_review": candidate["runtime_review"],
282
+ }
130
283
  (task_dir / "browser-review.json").write_text(json.dumps(candidate, indent=2) + "\n", encoding="utf-8")
131
284
  (task_dir / "task.json").write_text(json.dumps(task, indent=2) + "\n", encoding="utf-8")
132
285
  report_path = task_dir / "execution-report.json"
133
286
  report = read_json(report_path)
134
287
  report["status"] = "review_required"
135
288
  report["browser_review"] = [item for item in report.get("browser_review", []) if item.get("candidate_id") != cid]
136
- report["browser_review"].append({"candidate_id": cid, "decision": "pending", "evidence": ["browser-review.json"], "next_action": "Open the internal Dev Lab URL and ask the user to review."})
289
+ report["browser_review"].append({
290
+ "candidate_id": cid,
291
+ "decision": "pending",
292
+ "evidence": ["browser-review.json"] + ([RUNTIME_DESCRIPTOR] if bundle else []),
293
+ "next_action": review_instruction(bundle),
294
+ })
137
295
  report["next_agent"] = [item for item in report.get("next_agent", []) if item.get("id") != "browser-review"]
138
- report["next_agent"].append({"id": "browser-review", "summary": "Open the exact candidate in the internal Dev Lab browser.", "status": "pending", "agent": "browser-review-agent", "skill": "browser-review", "evidence_needed": ["review.json"], "next_action": "Ask user to approve or request changes."})
296
+ report["next_agent"].append({
297
+ "id": "browser-review",
298
+ "summary": "Open the exact candidate in the internal Dev Lab browser.",
299
+ "status": "pending",
300
+ "agent": "browser-review-agent",
301
+ "skill": "browser-review",
302
+ "evidence_needed": ["review.json"],
303
+ "next_action": review_instruction(bundle),
304
+ })
139
305
  report["generated_at"] = now()
140
306
  report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
141
307
  handoff_path = task_dir / "handoff.json"
142
308
  handoff = read_json(handoff_path)
143
309
  if memory_path.is_file():
144
310
  shutil.copy2(memory_path, task_dir / "project-memory.json")
145
- handoff.update({"state": "review_required", "to_agent": "browser-review-agent", "summary": "Open the exact rendered candidate in the internal Dev Lab and obtain user approval before PR.", "next_actions": [{"action": "Open internal Dev Lab candidate", "kind": "browser_review", "agent": "browser-review-agent", "skill": "browser-review", "url": url, "artifact_base": artifact_base, "task_base": task_base, "candidate_id": cid, "requires_user_approval": True, "evidence_needed": ["review.json"], "output_artifacts": ["review.json"]}], "required_artifacts": sorted(set(handoff.get("required_artifacts", []) + ["browser-review.json", "review.json", "semantic-lint-benchmark.json", "evidence-verifier-report.json", "runtime-adapters/runtime-evidence.json"] + (["project-memory.json"] if memory_path.is_file() else [])))})
311
+ handoff.update({
312
+ "state": "review_required",
313
+ "to_agent": "browser-review-agent",
314
+ "summary": "Open the exact rendered candidate in the internal Dev Lab and obtain user approval before PR.",
315
+ "next_actions": [{
316
+ "action": "Open internal Dev Lab candidate",
317
+ "kind": "browser_review",
318
+ "agent": "browser-review-agent",
319
+ "skill": "browser-review",
320
+ "url": url,
321
+ "artifact_base": artifact_base,
322
+ "task_base": task_base,
323
+ "candidate_id": cid,
324
+ "runtime_review": candidate["runtime_review"],
325
+ "requires_user_approval": True,
326
+ "evidence_needed": ["review.json"],
327
+ "output_artifacts": ["review.json"],
328
+ }],
329
+ "required_artifacts": sorted(set(
330
+ handoff.get("required_artifacts", [])
331
+ + ["browser-review.json", "review.json", "semantic-lint-benchmark.json", "evidence-verifier-report.json", "runtime-adapters/runtime-evidence.json"]
332
+ + ([RUNTIME_DESCRIPTOR] if bundle else [])
333
+ + (["project-memory.json"] if memory_path.is_file() else [])
334
+ )),
335
+ })
146
336
  handoff_path.write_text(json.dumps(handoff, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
147
337
  subprocess.run([sys.executable, str(ROOT / "scripts/devlab.py"), scene, "--prepare-only", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
148
338
  subprocess.run([sys.executable, str(ROOT / "scripts/report.py"), "collect", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
149
- print(json.dumps({"status": "review_required", "task_id": task["task_id"], "candidate_id": cid, "url": url, "agent": "browser-review-agent", "action": "Open this exact URL in the internal browser; ask the user to inspect frames 0/50/100 and approve or request changes.", "requires_user_approval": True, "output_artifacts": ["review.json"]}, ensure_ascii=False))
339
+ print(json.dumps({
340
+ "status": "review_required",
341
+ "task_id": task["task_id"],
342
+ "candidate_id": cid,
343
+ "url": url,
344
+ "agent": "browser-review-agent",
345
+ "action": review_instruction(bundle),
346
+ "runtime_review": candidate["runtime_review"],
347
+ "requires_user_approval": True,
348
+ "output_artifacts": ["review.json"],
349
+ }, ensure_ascii=False))
150
350
  return 0
151
351
 
152
352
 
@@ -156,8 +356,12 @@ def validate(args: argparse.Namespace) -> int:
156
356
  candidate = read_json(task_dir / "browser-review.json")
157
357
  scene_dir, source_path, render_meta, context_path = paths(task_dir, task)
158
358
  spec = read_json(scene_dir / "motion-spec.json")
359
+ bundle = runtime_bundle(scene_dir)
159
360
  context_hash = (spec.get("context_binding") or {}).get("context_sha256") or sha256(context_path)
160
- expected = candidate_id(task["task_id"], task["scene"], context_hash, sha256(source_path), sha256(render_meta))
361
+ expected = candidate_id(
362
+ task["task_id"], task["scene"], context_hash, sha256(source_path), sha256(render_meta),
363
+ bundle["bundle_sha256"] if bundle else None,
364
+ )
161
365
  errors = []
162
366
  if not candidate.get("expires_at"):
163
367
  errors.append("browser-review candidate has no expiry")
@@ -168,7 +372,7 @@ def validate(args: argparse.Namespace) -> int:
168
372
  except (TypeError, ValueError):
169
373
  errors.append("browser-review candidate expiry is invalid")
170
374
  if candidate.get("candidate_id") != expected:
171
- errors.append("candidate_id does not match task, context, source and runtime metadata")
375
+ errors.append("candidate_id does not match task, context, source, runtime metadata and live runtime bundle")
172
376
  if candidate.get("task_id") != task.get("task_id"):
173
377
  errors.append("candidate task_id does not match task.json")
174
378
  task_review = task.get("browser_review") or {}
@@ -184,6 +388,22 @@ def validate(args: argparse.Namespace) -> int:
184
388
  errors.append("candidate context_sha256 is stale")
185
389
  if candidate.get("status") not in {"prepared", "opened", "reviewed", "approved", "changes_requested", "expired"}:
186
390
  errors.append(f"browser-review candidate status is invalid: {candidate.get('status')}")
391
+
392
+ runtime_review = candidate.get("runtime_review") or {}
393
+ if bundle:
394
+ if runtime_review.get("live") is not True:
395
+ errors.append("candidate does not declare the available live runtime")
396
+ if runtime_review.get("descriptor") != RUNTIME_DESCRIPTOR:
397
+ errors.append("candidate live runtime descriptor binding is missing")
398
+ if runtime_review.get("mode") != bundle["mode"]:
399
+ errors.append("candidate live runtime mode is stale")
400
+ if runtime_review.get("bundle_sha256") != bundle["bundle_sha256"]:
401
+ errors.append("candidate live runtime bundle hash is stale")
402
+ if runtime_review.get("animations") != bundle["animations"]:
403
+ errors.append("candidate live runtime animation set is stale")
404
+ elif runtime_review.get("live") is True:
405
+ errors.append("candidate claims live runtime but devlab-runtime.json is missing")
406
+
187
407
  review = read_json(task_dir / "review.json", {})
188
408
  if review and review.get("candidate_id") != candidate.get("candidate_id"):
189
409
  errors.append("review.json approves a different candidate")
@@ -199,13 +419,29 @@ def validate(args: argparse.Namespace) -> int:
199
419
  errors.append("review.json was recorded after candidate expiry")
200
420
  except (TypeError, ValueError, KeyError):
201
421
  errors.append("review.json reviewed_at or candidate expiry is invalid")
422
+ if review.get("decision") == "approved" and bundle and bundle["review_policy"]["require_all_animations"]:
423
+ inspected = set(review.get("animations_inspected") or [])
424
+ required = {
425
+ animation["id"] for animation in bundle["descriptor"]["animations"]
426
+ if animation.get("review_required", True)
427
+ }
428
+ missing = sorted(required - inspected)
429
+ if missing:
430
+ errors.append(f"approved review did not inspect required animations: {', '.join(missing)}")
202
431
  if args.require_approved:
203
432
  if candidate.get("status") != "approved":
204
433
  errors.append("browser-review candidate is not approved")
205
434
  if not review or review.get("decision") != "approved":
206
435
  errors.append("review.json decision must be approved")
207
436
  status = "pass" if not errors else "fail"
208
- print(json.dumps({"status": status, "task_id": task.get("task_id"), "candidate_id": candidate.get("candidate_id"), "candidate_status": candidate.get("status"), "errors": errors}, ensure_ascii=False))
437
+ print(json.dumps({
438
+ "status": status,
439
+ "task_id": task.get("task_id"),
440
+ "candidate_id": candidate.get("candidate_id"),
441
+ "candidate_status": candidate.get("status"),
442
+ "runtime_review": runtime_review,
443
+ "errors": errors,
444
+ }, ensure_ascii=False))
209
445
  return 0 if not errors else 1
210
446
 
211
447
 
@@ -6,9 +6,11 @@ import { spawn } from "node:child_process";
6
6
  import { setTimeout as sleep } from "node:timers/promises";
7
7
  import { chromium } from "playwright";
8
8
  import crypto from "node:crypto";
9
+ import { fileURLToPath } from "node:url";
9
10
 
10
- const ROOT = path.resolve(new URL("..", import.meta.url).pathname);
11
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
12
  const outputRoot = path.resolve(process.env.RUNTIME_EVIDENCE_DIR || path.join(ROOT, "artifacts/runtime-adapters"));
13
+ const outputPolicyRoot = path.resolve(process.env.MOTIONLOOM_RUNTIME_OUTPUT_ROOT || ROOT);
12
14
  const port = Number(process.env.RUNTIME_HARNESS_PORT || 4179);
13
15
  const supportedFrameworks = new Set(["rive", "gsap", "framer-motion"]);
14
16
  const frameworks = (process.env.RUNTIME_FRAMEWORKS || "rive,gsap,framer-motion")
@@ -17,8 +19,31 @@ const unsupported = frameworks.filter((name) => !supportedFrameworks.has(name));
17
19
  if (unsupported.length) {
18
20
  throw new Error(`unsupported runtime framework(s): ${unsupported.join(", ")}`);
19
21
  }
20
- if (outputRoot === ROOT || outputRoot === path.parse(outputRoot).root) {
21
- throw new Error("RUNTIME_EVIDENCE_DIR must be a dedicated child output directory");
22
+ function canonicalPath(target) {
23
+ let existing = target;
24
+ const missing = [];
25
+ while (!fs.existsSync(existing)) {
26
+ const parent = path.dirname(existing);
27
+ if (parent === existing) break;
28
+ missing.unshift(path.basename(existing));
29
+ existing = parent;
30
+ }
31
+ const base = fs.existsSync(existing) ? fs.realpathSync(existing) : path.resolve(existing);
32
+ return path.resolve(base, ...missing);
33
+ }
34
+
35
+ function isStrictChild(target, parent) {
36
+ const relative = path.relative(parent, target);
37
+ return Boolean(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
38
+ }
39
+
40
+ const canonicalOutputRoot = canonicalPath(outputRoot);
41
+ const canonicalPolicyRoot = canonicalPath(outputPolicyRoot);
42
+ if (!isStrictChild(canonicalOutputRoot, canonicalPolicyRoot)) {
43
+ throw new Error(
44
+ `RUNTIME_EVIDENCE_DIR must be a dedicated child of ${outputPolicyRoot}; ` +
45
+ "set MOTIONLOOM_RUNTIME_OUTPUT_ROOT explicitly to authorize another parent",
46
+ );
22
47
  }
23
48
  const baseUrl = `http://127.0.0.1:${port}`;
24
49
  const runId = `${Date.now()}-${process.pid}`;
@@ -4,6 +4,7 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
+ import importlib.util
7
8
  import json
8
9
  import re
9
10
  import sys
@@ -11,7 +12,15 @@ from pathlib import Path
11
12
 
12
13
 
13
14
  ROOT = Path(__file__).resolve().parents[1]
14
- REQUIRED_FILES = ["SKILL.md", "agent-card.json", "package.json"]
15
+ REQUIRED_FILES = [
16
+ "SKILL.md",
17
+ "agent-card.json",
18
+ "package.json",
19
+ "requirements.txt",
20
+ "dev-lab/public/index.html",
21
+ "dev-lab/public/devlab.js",
22
+ "tests/runtime-harness/index.html",
23
+ ]
15
24
  REQUIRED_DIRS = ["scripts", "templates", "references", "schemas"]
16
25
  REQUIRED_SCRIPT_FILES = [
17
26
  "scripts/report-contract.py",
@@ -135,9 +144,26 @@ def run() -> int:
135
144
  for script in ("test", "validate", "doctor", "setup", "setup:dry", "status", "repair", "report", "report:check", "review", "memory:bootstrap", "memory:recover", "memory:validate", "devlab", "pack:dotlottie"):
136
145
  if script not in package.get("scripts", {}):
137
146
  warnings.append({"code": "missing_package_script", "message": f"package.json has no {script} script."})
147
+ runtime_dependencies = {
148
+ **package.get("dependencies", {}),
149
+ **package.get("optionalDependencies", {}),
150
+ }
151
+ for dependency in ("playwright", "vite"):
152
+ present = dependency in runtime_dependencies
153
+ checks.append({"id": f"runtime-dependency:{dependency}", "status": "pass" if present else "fail"})
154
+ if not present:
155
+ errors.append({"code": "missing_runtime_dependency", "message": f"package.json runtime dependencies omit {dependency}."})
138
156
  except (FileNotFoundError, json.JSONDecodeError) as exc:
139
157
  errors.append({"code": "invalid_package_json", "message": str(exc)})
140
158
 
159
+ cryptography_available = importlib.util.find_spec("cryptography") is not None
160
+ checks.append({"id": "python-dependency:cryptography", "status": "pass" if cryptography_available else "fail"})
161
+ if not cryptography_available:
162
+ errors.append({
163
+ "code": "missing_python_dependency",
164
+ "message": "Python package cryptography is required; install dependencies from requirements.txt.",
165
+ })
166
+
141
167
  result = {
142
168
  "doctor_version": "1.0",
143
169
  "skill_root": str(ROOT),
@@ -0,0 +1,24 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Animation Skill Runtime Harness</title>
7
+ <style>
8
+ html, body { margin: 0; min-height: 100%; background: #111827; color: #f8fafc; font-family: system-ui, sans-serif; }
9
+ body { display: grid; place-items: center; }
10
+ #root { width: 512px; height: 512px; display: grid; place-items: center; }
11
+ .stage { position: relative; width: 420px; height: 420px; overflow: hidden; background: #f8fafc; border-radius: 12px; }
12
+ .stage::after { content: ""; position: absolute; inset: 0; pointer-events: none; border: 1px solid rgba(15,23,42,.2); border-radius: inherit; }
13
+ .gsap-box, .motion-box { width: 96px; height: 96px; position: absolute; left: 40px; top: 160px; border-radius: 18px; background: #2563eb; transform-origin: center; }
14
+ .gsap-box::after, .motion-box::after { content: ""; position: absolute; width: 16px; height: 16px; border-radius: 50%; right: 14px; top: 14px; background: #f59e0b; }
15
+ canvas { width: 420px; height: 420px; display: block; }
16
+ #status { position: fixed; left: 12px; bottom: 12px; font: 12px ui-monospace, monospace; color: #fbbf24; }
17
+ </style>
18
+ </head>
19
+ <body>
20
+ <div id="root"></div>
21
+ <div id="status">loading</div>
22
+ <script type="module" src="/tests/runtime-harness/main.jsx"></script>
23
+ </body>
24
+ </html>