motionloom 2.5.0 → 2.5.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/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ All notable MotionLoom changes are documented here. The project follows semantic
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.5.1] - 2026-08-18
8
+
9
+ ### Fixed
10
+
11
+ - Preserve the caller's working directory through the npm CLI router, so default onboarding targets the consumer project instead of the installed package directory.
12
+ - Ship Dev Lab assets, the runtime harness, Python requirements and optional browser runtime dependencies in the npm tarball; verify the installed package from an isolated consumer project before release.
13
+ - Render Dev Lab metadata and checks with safe DOM APIs, enforce a same-origin script CSP, and reject invalid, terminal or expired browser-review candidates before enabling review.
14
+ - Restrict runtime evidence cleanup to a dedicated child of an explicit policy root and preserve caller data outside that boundary.
15
+ - Read the CLI version from `package.json` so displayed status stays aligned with release metadata.
16
+ - Publish from GitHub Actions through npm Trusted Publishing/OIDC with a pinned compatible npm client, without a long-lived `NPM_TOKEN`; provenance is generated automatically by npm.
17
+
18
+ ### Verified
19
+
20
+ - Pass the full Python/Node regression suite, quality and signed-attestation gate, isolated installed-package smoke test, Dev Lab browser-security smoke test, real Rive/GSAP/Framer Motion runtime capture, 6,900-case deep stress run and twelve Swift package tests.
21
+ - Pass the pull-request Quality, Security Analysis, Documentation and Package Hygiene, Dev Lab and Apple workflows before preparing this patch release.
22
+
23
+ ### Boundary
24
+
25
+ - This patch does not add framework capability, Apple signing, TestFlight distribution or production approval. Browser review and remote release actions remain explicit human-governed steps.
26
+
7
27
  ## [2.5.0] - 2026-08-17
8
28
 
9
29
  ### Added
package/README.md CHANGED
@@ -15,7 +15,7 @@ MotionLoom is an independent open-source Agent Skill for building UI motion, Lot
15
15
 
16
16
  > **MotionLoom is not an auto-approval layer.** A valid signature, a passing heuristic, or a successful render proves only the contract it checks. Visual quality, intent, accessibility and PR authorization remain reviewable human decisions.
17
17
 
18
- > **Release posture:** MotionLoom 2.5.0 adds an evidence-bound Agent capability card, benchmark-informed runtime discovery, and the native macOS/iOS review alpha in the GitHub source release under `apps/apple/`. The npm package remains the cross-platform Node/Python Skill and documentation surface; it does not distribute or sign the Xcode app. Verify npm/GitHub publication metadata separately; passing evidence never implies user approval.
18
+ > **Release posture:** MotionLoom 2.5.1 hardens installed-package onboarding, Dev Lab browser review, runtime-output cleanup and consumer-package acceptance. The native macOS/iOS review app remains an unsigned source alpha under `apps/apple/`; the npm package remains the cross-platform Node/Python Skill and documentation surface. Verify npm/GitHub publication metadata separately; passing evidence never implies user approval.
19
19
 
20
20
  ## Why MotionLoom
21
21
 
@@ -77,7 +77,7 @@ npx --no-install motionloom doctor # check the installed package
77
77
  npx --no-install motionloom repair --yes # restore only missing managed pieces
78
78
  ```
79
79
 
80
- MotionLoom supports **Node.js 18+** and **Python 3.11+** on Ubuntu, macOS and Windows. `npx` is the recommended first-run surface; after setup, use the project-local binary through `npx --no-install motionloom ...`. A global install remains optional, not required.
80
+ MotionLoom's onboarding and contract CLI supports **Node.js 18+** and **Python 3.11+** on Ubuntu, macOS and Windows. Browser-runtime capture uses the separately declared optional Playwright/Vite dependencies and follows the capability contract's Node.js 22 requirement. Signed-attestation commands additionally require the Python `cryptography` package declared in the shipped `requirements.txt`; `motionloom doctor` reports missing runtime prerequisites. `npx` is the recommended first-run surface; after setup, use the project-local binary through `npx --no-install motionloom ...`. A global install remains optional, not required.
81
81
 
82
82
  ### When you begin animation work
83
83
 
@@ -324,6 +324,7 @@ Read [the Apple workspace guide](apps/apple/README.md), [the contract boundary](
324
324
 
325
325
  ```bash
326
326
  npm install
327
+ python3 -m pip install --requirement requirements.txt
327
328
  python3 scripts/skill-doctor.py --json
328
329
  python3 tests/scripts/run_tests.py
329
330
  python3 scripts/eval-intelligence.py
package/SKILL.md CHANGED
@@ -7,7 +7,7 @@ description: >-
7
7
  validate, review, or deliver animation inside an existing project.
8
8
  license: MIT
9
9
  metadata:
10
- version: "2.5.0"
10
+ version: "2.5.1"
11
11
  target_frameworks: "lottie,dotlottie,rive,gsap,framer-motion,spine,threejs"
12
12
  verified_runtimes: "lottie-json,dotlottie-package,svg-cutout-rig,rive,gsap,framer-motion"
13
13
  ---
package/agent-card.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "card_version": "1.0",
3
3
  "name": "motionloom",
4
- "version": "2.5.0",
4
+ "version": "2.5.1",
5
5
  "description": "Project-aware animation production with durable Project Memory, provider-neutral Artifact Intake, hash-bound control-to-runtime candidates, tiered asset provenance, framework-neutral motion IR, rig compatibility, runtime verification and telemetry, Visual Truth, Remediation Learning, DSSE-compatible signed attestation, internal browser review, trust-boundary hardening and deterministic Agent handoff.",
6
6
  "capabilities": [
7
7
  "project.setup",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": "1.0",
3
3
  "name": "motionloom",
4
- "version": "2.5.0",
4
+ "version": "2.5.1",
5
5
  "canonical": {
6
6
  "skill": "SKILL.md",
7
7
  "agent_card": "agent-card.json",
@@ -8,8 +8,11 @@
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { dirname, resolve } from "node:path";
10
10
  import { spawnSync } from "node:child_process";
11
+ import { readFileSync } from "node:fs";
11
12
 
12
13
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
14
+ const PACKAGE = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8"));
15
+ const VERSION = PACKAGE.version || "unknown";
13
16
  const PYTHON = process.env.MOTIONLOOM_PYTHON || (process.platform === "win32" ? "python" : "python3");
14
17
 
15
18
  const PYTHON_COMMANDS = {
@@ -32,8 +35,6 @@ const PYTHON_COMMANDS = {
32
35
  pr: "scripts/pr.py",
33
36
  "validate-lottie": "scripts/validate-lottie.py",
34
37
  manifest: "scripts/manifest.py",
35
- test: "tests/scripts/run_tests.py",
36
- "deep-audit": "tests/scripts/deep-stress.py",
37
38
  discovery: "scripts/discovery.py",
38
39
  "visual-truth": "scripts/visual-truth.py",
39
40
  "remediation-learning": "scripts/remediation-learning.py",
@@ -59,7 +60,7 @@ const COMMAND_ALIASES = {
59
60
  };
60
61
 
61
62
  function printHelp() {
62
- console.log(`MotionLoom 2.4.0 — project-aware animation production and evidence contracts
63
+ console.log(`MotionLoom ${VERSION} — project-aware animation production and evidence contracts
63
64
 
64
65
  Usage:
65
66
  motionloom <command> [args...]
@@ -147,7 +148,7 @@ const delegatedArgs = alias
147
148
  ? ["init", ...args]
148
149
  : args;
149
150
  const result = spawnSync(executable, [resolve(ROOT, script), ...delegatedArgs], {
150
- cwd: ROOT,
151
+ cwd: process.cwd(),
151
152
  stdio: "inherit",
152
153
  env: process.env,
153
154
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": "0.1",
3
- "registry_id": "registry-motionloom-2.5.0",
4
- "generated_at": "2026-08-17T05:39:24Z",
3
+ "registry_id": "registry-motionloom-2.5.1",
4
+ "generated_at": "2026-08-17T23:50:53Z",
5
5
  "selection_policy": {
6
6
  "require_verified": true,
7
7
  "allow_scaffold_only": false,
@@ -12,7 +12,7 @@
12
12
  "id": "runtime.lottie-json",
13
13
  "kind": "renderer",
14
14
  "status": "verified",
15
- "adapter_version": "2.5.0",
15
+ "adapter_version": "2.5.1",
16
16
  "inputs": [
17
17
  "project-context",
18
18
  "motion-spec",
@@ -30,11 +30,11 @@
30
30
  ],
31
31
  "node": ">=22"
32
32
  },
33
- "last_verified_at": "2026-08-17T05:39:24Z",
33
+ "last_verified_at": "2026-08-17T23:50:53Z",
34
34
  "evidence": [
35
35
  {
36
36
  "path": "scripts/runtime-adapters.mjs",
37
- "sha256": "711a64c07b4d82f646d066c7b0d91deaefc5c9c0fb701358706bf31d558b4d89",
37
+ "sha256": "6f135e75e700a7bb105903db59445fcd3577669b595082a2b542db639f5fe56d",
38
38
  "kind": "ci"
39
39
  }
40
40
  ],
@@ -49,7 +49,7 @@
49
49
  "id": "runtime.dotlottie-package",
50
50
  "kind": "packager",
51
51
  "status": "verified",
52
- "adapter_version": "2.5.0",
52
+ "adapter_version": "2.5.1",
53
53
  "inputs": [
54
54
  "project-context",
55
55
  "motion-spec",
@@ -67,11 +67,11 @@
67
67
  ],
68
68
  "node": ">=22"
69
69
  },
70
- "last_verified_at": "2026-08-17T05:39:24Z",
70
+ "last_verified_at": "2026-08-17T23:50:53Z",
71
71
  "evidence": [
72
72
  {
73
73
  "path": "scripts/runtime-adapters.mjs",
74
- "sha256": "711a64c07b4d82f646d066c7b0d91deaefc5c9c0fb701358706bf31d558b4d89",
74
+ "sha256": "6f135e75e700a7bb105903db59445fcd3577669b595082a2b542db639f5fe56d",
75
75
  "kind": "ci"
76
76
  }
77
77
  ],
@@ -86,7 +86,7 @@
86
86
  "id": "runtime.svg-cutout-rig",
87
87
  "kind": "renderer",
88
88
  "status": "verified",
89
- "adapter_version": "2.5.0",
89
+ "adapter_version": "2.5.1",
90
90
  "inputs": [
91
91
  "project-context",
92
92
  "motion-spec",
@@ -104,11 +104,11 @@
104
104
  ],
105
105
  "node": ">=22"
106
106
  },
107
- "last_verified_at": "2026-08-17T05:39:24Z",
107
+ "last_verified_at": "2026-08-17T23:50:53Z",
108
108
  "evidence": [
109
109
  {
110
110
  "path": "scripts/runtime-adapters.mjs",
111
- "sha256": "711a64c07b4d82f646d066c7b0d91deaefc5c9c0fb701358706bf31d558b4d89",
111
+ "sha256": "6f135e75e700a7bb105903db59445fcd3577669b595082a2b542db639f5fe56d",
112
112
  "kind": "ci"
113
113
  }
114
114
  ],
@@ -123,7 +123,7 @@
123
123
  "id": "runtime.rive",
124
124
  "kind": "runtime-adapter",
125
125
  "status": "verified",
126
- "adapter_version": "2.5.0",
126
+ "adapter_version": "2.5.1",
127
127
  "inputs": [
128
128
  "project-context",
129
129
  "motion-spec",
@@ -141,11 +141,11 @@
141
141
  ],
142
142
  "node": ">=22"
143
143
  },
144
- "last_verified_at": "2026-08-17T05:39:24Z",
144
+ "last_verified_at": "2026-08-17T23:50:53Z",
145
145
  "evidence": [
146
146
  {
147
147
  "path": "scripts/runtime-adapters.mjs",
148
- "sha256": "711a64c07b4d82f646d066c7b0d91deaefc5c9c0fb701358706bf31d558b4d89",
148
+ "sha256": "6f135e75e700a7bb105903db59445fcd3577669b595082a2b542db639f5fe56d",
149
149
  "kind": "ci"
150
150
  }
151
151
  ],
@@ -160,7 +160,7 @@
160
160
  "id": "runtime.gsap",
161
161
  "kind": "runtime-adapter",
162
162
  "status": "verified",
163
- "adapter_version": "2.5.0",
163
+ "adapter_version": "2.5.1",
164
164
  "inputs": [
165
165
  "project-context",
166
166
  "motion-spec",
@@ -178,11 +178,11 @@
178
178
  ],
179
179
  "node": ">=22"
180
180
  },
181
- "last_verified_at": "2026-08-17T05:39:24Z",
181
+ "last_verified_at": "2026-08-17T23:50:53Z",
182
182
  "evidence": [
183
183
  {
184
184
  "path": "scripts/runtime-adapters.mjs",
185
- "sha256": "711a64c07b4d82f646d066c7b0d91deaefc5c9c0fb701358706bf31d558b4d89",
185
+ "sha256": "6f135e75e700a7bb105903db59445fcd3577669b595082a2b542db639f5fe56d",
186
186
  "kind": "ci"
187
187
  }
188
188
  ],
@@ -197,7 +197,7 @@
197
197
  "id": "runtime.framer-motion",
198
198
  "kind": "runtime-adapter",
199
199
  "status": "verified",
200
- "adapter_version": "2.5.0",
200
+ "adapter_version": "2.5.1",
201
201
  "inputs": [
202
202
  "project-context",
203
203
  "motion-spec",
@@ -215,11 +215,11 @@
215
215
  ],
216
216
  "node": ">=22"
217
217
  },
218
- "last_verified_at": "2026-08-17T05:39:24Z",
218
+ "last_verified_at": "2026-08-17T23:50:53Z",
219
219
  "evidence": [
220
220
  {
221
221
  "path": "scripts/runtime-adapters.mjs",
222
- "sha256": "711a64c07b4d82f646d066c7b0d91deaefc5c9c0fb701358706bf31d558b4d89",
222
+ "sha256": "6f135e75e700a7bb105903db59445fcd3577669b595082a2b542db639f5fe56d",
223
223
  "kind": "ci"
224
224
  }
225
225
  ],
@@ -234,7 +234,7 @@
234
234
  "id": "runtime.spine",
235
235
  "kind": "renderer",
236
236
  "status": "scaffold_only",
237
- "adapter_version": "2.5.0",
237
+ "adapter_version": "2.5.1",
238
238
  "inputs": [
239
239
  "project-context",
240
240
  "motion-spec",
@@ -252,11 +252,11 @@
252
252
  ],
253
253
  "node": ">=22"
254
254
  },
255
- "last_verified_at": "2026-08-17T05:39:24Z",
255
+ "last_verified_at": "2026-08-17T23:50:53Z",
256
256
  "evidence": [
257
257
  {
258
258
  "path": "scripts/runtime-adapters.mjs",
259
- "sha256": "711a64c07b4d82f646d066c7b0d91deaefc5c9c0fb701358706bf31d558b4d89",
259
+ "sha256": "6f135e75e700a7bb105903db59445fcd3577669b595082a2b542db639f5fe56d",
260
260
  "kind": "ci"
261
261
  }
262
262
  ],
@@ -271,7 +271,7 @@
271
271
  "id": "runtime.threejs",
272
272
  "kind": "renderer",
273
273
  "status": "scaffold_only",
274
- "adapter_version": "2.5.0",
274
+ "adapter_version": "2.5.1",
275
275
  "inputs": [
276
276
  "project-context",
277
277
  "motion-spec",
@@ -289,11 +289,11 @@
289
289
  ],
290
290
  "node": ">=22"
291
291
  },
292
- "last_verified_at": "2026-08-17T05:39:24Z",
292
+ "last_verified_at": "2026-08-17T23:50:53Z",
293
293
  "evidence": [
294
294
  {
295
295
  "path": "scripts/runtime-adapters.mjs",
296
- "sha256": "711a64c07b4d82f646d066c7b0d91deaefc5c9c0fb701358706bf31d558b4d89",
296
+ "sha256": "6f135e75e700a7bb105903db59445fcd3577669b595082a2b542db639f5fe56d",
297
297
  "kind": "ci"
298
298
  }
299
299
  ],
@@ -0,0 +1,193 @@
1
+ const params = new URLSearchParams(location.search);
2
+ const scene = params.get("scene");
3
+ const taskId = params.get("task_id") || "unbound";
4
+ const candidateId = params.get("candidate_id");
5
+ const base = `/scenes/${encodeURIComponent(scene || "")}`;
6
+ const frames = [0, 50, 100];
7
+ const allowedStatuses = new Set(["prepared", "opened"]);
8
+ const $ = (id) => document.getElementById(id);
9
+ let manifest;
10
+ let spec;
11
+ let candidate;
12
+ let review;
13
+
14
+ function nearest(progress) {
15
+ return frames.reduce(
16
+ (current, frame) => Math.abs(frame - progress) < Math.abs(current - progress) ? frame : current,
17
+ frames[0],
18
+ );
19
+ }
20
+
21
+ function showProgress(progress) {
22
+ const bounded = Math.max(0, Math.min(1, progress));
23
+ const percent = bounded * 100;
24
+ const frame = nearest(percent);
25
+ $("scrubber").value = percent;
26
+ $("timecode").textContent = `00:00:${(Number(spec?.duration_s || 0) * bounded).toFixed(3).padStart(6, "0")}`;
27
+ $("frame").src = `${base}/snapshot/frame-${String(frame).padStart(2, "0")}.png`;
28
+ $("frame").hidden = false;
29
+ $("empty").hidden = true;
30
+ window.__lab.lastProgress = bounded;
31
+ }
32
+
33
+ function currentChecks() {
34
+ return [...document.querySelectorAll("[data-check]")].map((element) => ({
35
+ id: element.dataset.check,
36
+ pass: element.checked,
37
+ }));
38
+ }
39
+
40
+ function currentReview(decision) {
41
+ return {
42
+ review_version: "1.0",
43
+ task_id: taskId,
44
+ candidate_id: candidate.candidate_id,
45
+ scene,
46
+ decision,
47
+ reviewer: "user",
48
+ reviewed_at: new Date().toISOString(),
49
+ checks: currentChecks(),
50
+ notes: $("notes").value,
51
+ frames_inspected: frames,
52
+ spec: {
53
+ framework: spec.framework,
54
+ category: spec.category,
55
+ context_binding: spec.context_binding,
56
+ },
57
+ candidate: {
58
+ source_sha256: candidate.source_sha256,
59
+ context_sha256: candidate.context_sha256,
60
+ },
61
+ };
62
+ }
63
+
64
+ function saveLocal(payload) {
65
+ localStorage.setItem(`devlab:${taskId}:${candidate.candidate_id}`, JSON.stringify(payload));
66
+ }
67
+
68
+ function expose(payload) {
69
+ window.__lab.lastReview = payload;
70
+ window.__lab.getReview = () => window.__lab.lastReview;
71
+ window.__lab.exportReview = () => JSON.stringify(window.__lab.lastReview, null, 2);
72
+ }
73
+
74
+ async function fetchJson(url) {
75
+ const response = await fetch(url);
76
+ if (!response.ok) throw new Error(`Could not load ${url} (${response.status})`);
77
+ return response.json();
78
+ }
79
+
80
+ function validateCandidate() {
81
+ if (candidate.candidate_id !== candidateId) {
82
+ throw new Error("Candidate identity mismatch; refusing to review a different scene");
83
+ }
84
+ if (candidate.task_id !== taskId) {
85
+ throw new Error("Task identity mismatch; refusing unbound review");
86
+ }
87
+ if (!allowedStatuses.has(candidate.status)) {
88
+ throw new Error(`Candidate status is not reviewable: ${candidate.status || "missing"}`);
89
+ }
90
+ const expiresAt = Date.parse(candidate.expires_at);
91
+ if (!Number.isFinite(expiresAt)) {
92
+ throw new Error("Candidate expiry is missing or invalid");
93
+ }
94
+ if (Date.now() > expiresAt) {
95
+ throw new Error("Candidate has expired; prepare a new browser-review URL");
96
+ }
97
+ }
98
+
99
+ function renderMetadata() {
100
+ const values = [
101
+ ["TASK", taskId],
102
+ ["CANDIDATE", candidateId],
103
+ ["FRAMEWORK", spec.framework],
104
+ ["DURATION", `${spec.duration_s}s`],
105
+ ["FPS", spec.fps],
106
+ ["EASING", spec.easing],
107
+ ["PRIMARY", spec.theme?.primary || "unbound"],
108
+ ];
109
+ const nodes = values.map(([key, value]) => {
110
+ const pill = document.createElement("div");
111
+ pill.className = "pill";
112
+ const label = document.createElement("b");
113
+ label.textContent = key;
114
+ pill.append(label, document.createTextNode(` ${value ?? "—"}`));
115
+ return pill;
116
+ });
117
+ $("meta").replaceChildren(...nodes);
118
+ }
119
+
120
+ function renderChecks() {
121
+ const nodes = (manifest.checks || []).map((check) => {
122
+ const label = document.createElement("label");
123
+ label.className = "check";
124
+ const input = document.createElement("input");
125
+ input.type = "checkbox";
126
+ input.dataset.check = String(check.id || "");
127
+ input.checked = Boolean(review.checks.find((item) => item.id === check.id)?.pass);
128
+ const copy = document.createElement("span");
129
+ const title = document.createElement("b");
130
+ const detail = document.createElement("small");
131
+ title.textContent = check.label || check.id || "Unnamed check";
132
+ detail.textContent = check.detail || "";
133
+ copy.append(title, detail);
134
+ label.append(input, copy);
135
+ return label;
136
+ });
137
+ $("checks").replaceChildren(...nodes);
138
+ }
139
+
140
+ function confirmReview() {
141
+ const checks = currentChecks();
142
+ const approved = checks.length > 0 && checks.every((check) => check.pass);
143
+ const payload = currentReview(approved ? "approved" : "changes_requested");
144
+ saveLocal(payload);
145
+ expose(payload);
146
+ $("status").className = approved ? "ok" : "warn";
147
+ $("status").textContent = approved
148
+ ? "BROWSER REVIEW APPROVED · browser Agent must persist review.json before PR"
149
+ : "CHANGES REQUESTED · browser Agent must persist notes and return to generation";
150
+ }
151
+
152
+ window.__lab = { seek: showProgress, ready: false, taskId, candidateId, reviewRequired: true };
153
+
154
+ async function load() {
155
+ if (!scene || !candidateId) {
156
+ throw new Error("Missing scene or candidate_id; open the emitted browser-review URL");
157
+ }
158
+ [manifest, spec, candidate] = await Promise.all([
159
+ fetchJson(`${base}/manifest.json`),
160
+ fetchJson(`${base}/motion-spec.json`),
161
+ fetchJson(`${base}/browser-review.json`),
162
+ ]);
163
+ validateCandidate();
164
+
165
+ $("title").textContent = `${manifest.name || scene} · candidate ${candidateId}`;
166
+ $("description").textContent = `${manifest.description || "Evidence review for this animation scene."} Review candidate ${candidateId} is bound to task ${taskId}.`;
167
+ $("stage-label").textContent = `${scene.toUpperCase()} · ${spec.category || "animation"} · CANDIDATE`;
168
+ $("stage-mode").textContent = `${spec.framework || "unknown"} · ${candidate.status} · runtime evidence`;
169
+ renderMetadata();
170
+
171
+ const saved = JSON.parse(localStorage.getItem(`devlab:${taskId}:${candidateId}`) || "null");
172
+ review = saved || {
173
+ checks: (manifest.checks || []).map((check) => ({ id: check.id, pass: false })),
174
+ notes: "",
175
+ decision: "pending",
176
+ };
177
+ renderChecks();
178
+ $("notes").value = review.notes || "";
179
+ $("scrubber").addEventListener("input", (event) => showProgress(Number(event.target.value) / 100));
180
+ $("confirm").addEventListener("click", confirmReview);
181
+ $("reset").addEventListener("click", () => {
182
+ localStorage.removeItem(`devlab:${taskId}:${candidateId}`);
183
+ location.reload();
184
+ });
185
+ window.__lab.ready = true;
186
+ showProgress(0);
187
+ }
188
+
189
+ load().catch((error) => {
190
+ $("status").className = "warn";
191
+ $("status").textContent = `DEV LAB ERROR: ${error.message}`;
192
+ $("empty").textContent = "Unable to load the exact candidate evidence.";
193
+ });
@@ -0,0 +1,30 @@
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" />
6
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'" />
7
+ <title>Animation Dev Lab</title>
8
+ <style>
9
+ :root { color-scheme: dark; --bg:#111218; --panel:#1b1d25; --line:#303440; --text:#e7e9ee; --muted:#8f96a6; --amber:#f59e0b; --good:#46d18c; --bad:#ff6b6b; }
10
+ * { box-sizing:border-box; } body { margin:0; background:radial-gradient(circle at 40% -10%,#2a2630 0,#111218 40%); color:var(--text); font:14px/1.45 ui-sans-serif,system-ui,sans-serif; }
11
+ header { height:58px; display:flex; align-items:center; justify-content:space-between; padding:0 22px; border-bottom:1px solid var(--line); background:#15171e; }
12
+ header strong { letter-spacing:.12em; text-transform:uppercase; font-size:12px; } header span { color:var(--amber); font-family:ui-monospace,monospace; }
13
+ main { display:grid; grid-template-columns:minmax(0,1fr) 330px; min-height:calc(100vh - 58px); } .workbench { padding:24px; min-width:0; }
14
+ .stage { min-height:430px; display:flex; align-items:center; justify-content:center; border:1px solid #3b3f4c; background:repeating-conic-gradient(#181a22 0 25%,#15171e 0 50%) 50%/32px 32px; position:relative; overflow:hidden; }
15
+ .stage img { max-width:92%; max-height:390px; object-fit:contain; image-rendering:auto; filter:drop-shadow(0 18px 32px #0009); } .stage .empty { color:var(--muted); text-align:center; }
16
+ .stage-label { position:absolute; left:14px; top:12px; color:var(--muted); font:11px ui-monospace,monospace; letter-spacing:.08em; } .stage-mode { position:absolute; right:14px; top:12px; color:var(--amber); font:11px ui-monospace,monospace; }
17
+ .timeline { padding:18px 0 0; } input[type=range] { width:100%; accent-color:var(--amber); } .ticks { display:flex; justify-content:space-between; color:var(--muted); font:11px ui-monospace,monospace; }
18
+ .meta-row { display:flex; gap:10px; flex-wrap:wrap; margin-top:18px; } .pill { border:1px solid var(--line); background:#191b22; padding:8px 10px; color:var(--muted); } .pill b { color:var(--text); font-weight:600; }
19
+ aside { border-left:1px solid var(--line); background:#171920; padding:20px; } h1 { font-size:24px; margin:0 0 6px; } h2 { font-size:13px; letter-spacing:.09em; text-transform:uppercase; color:var(--muted); margin:24px 0 10px; }
20
+ p { color:var(--muted); margin:0; } .check { display:flex; gap:10px; padding:9px 0; border-bottom:1px solid #272a34; } .check input { accent-color:var(--good); } .check small { color:var(--muted); display:block; } textarea { width:100%; min-height:90px; resize:vertical; color:var(--text); background:#111218; border:1px solid var(--line); padding:10px; font:13px ui-monospace,monospace; }
21
+ button { border:0; padding:11px 14px; color:#17120b; background:var(--amber); font-weight:750; cursor:pointer; } button:hover { filter:brightness(1.08); } button:active { transform:scale(.98); } button.secondary { color:var(--text); background:#2a2d38; margin-left:8px; } .actions { margin-top:14px; } #status { margin-top:10px; font:12px ui-monospace,monospace; } .ok { color:var(--good); } .warn { color:var(--bad); }
22
+ @media (max-width:800px) { main { grid-template-columns:1fr; } aside { border-left:0; border-top:1px solid var(--line); } .stage { min-height:330px; } }
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <header><strong>Animation Dev Lab</strong><span id="timecode">00:00:00.000</span></header>
27
+ <main><section class="workbench"><div class="stage" id="stage" data-snapshot><div class="stage-label" id="stage-label">LOADING SCENE</div><div class="stage-mode" id="stage-mode"></div><div class="empty" id="empty">Loading evidence…</div><img id="frame" alt="Rendered animation snapshot" hidden /></div><div class="timeline"><input id="scrubber" type="range" min="0" max="100" value="0" aria-label="Animation progress" /><div class="ticks"><span>00%</span><span>25%</span><span>50%</span><span>75%</span><span>100%</span></div></div><div class="meta-row" id="meta"></div></section><aside><h1 id="title">Scene</h1><p id="description">Loading manifest and signed motion spec.</p><h2>Quality checklist</h2><div id="checks"></div><h2>Fix notes</h2><textarea id="notes" placeholder="Describe a fix request or confirmation for the PR…"></textarea><div class="actions"><button id="confirm">Confirm review</button><button class="secondary" id="reset">Reset</button></div><div id="status"></div></aside></main>
28
+ <script src="/devlab.js" defer></script>
29
+ </body>
30
+ </html>
package/docs/STATUS.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Current release posture
6
6
 
7
- MotionLoom's repository source and package manifest are prepared for **2.4.0 release metadata**. The publication state of the npm registry and GitHub Release/tag must be verified against the latest release workflow rather than inferred from this document. The repository has a cross-platform CLI, one-command project onboarding, durable Project Memory, Agent interoperability surfaces, artifact-first handoff, runtime evidence, Visual Truth, Remediation Learning, signed attestation, AI-first asset provenance, a truthful code-authored runtime lane and a browser-based Dev Lab review contract. The protected manual release workflow verifies package/changelog/release-note alignment before publication.
7
+ MotionLoom's repository source and package manifest carry **2.5.1 release metadata**. The publication state of the npm registry and GitHub Release/tag must be verified against the latest release workflow rather than inferred from this document. The repository has a cross-platform CLI, one-command project onboarding, durable Project Memory, Agent interoperability surfaces, artifact-first handoff, runtime evidence, Visual Truth, Remediation Learning, signed attestation, AI-first asset provenance, a truthful code-authored runtime lane and a browser-based Dev Lab review contract. The protected manual release workflow verifies package/changelog/release-note alignment before publication.
8
8
 
9
9
  The repository is **engineering-ready for continued integration work**, not a universal production certification for every animation framework or every host project. CI status must be read from the latest GitHub Actions run, not inferred from historical audit prose.
10
10
 
@@ -30,4 +30,4 @@ Use the following order when sources disagree:
30
30
 
31
31
  ## Known next work
32
32
 
33
- The bounded analyzer has now been exercised against a labeled external corpus; see the [dated evidence note](audits/external-project-corpus-2026-08-13.md). The highest-value remaining evidence work is paired product evaluation across more real projects and verification of the 2.4.0 version–tag–GitHub Release–npm provenance chain. Missing external projects are reported as `insufficient_evidence`, never as a pass. None of these items should weaken the user-review gate or convert heuristic warnings into approval.
33
+ The bounded analyzer has now been exercised against a labeled external corpus; see the [dated evidence note](audits/external-project-corpus-2026-08-13.md). The 2.5.0 version–tag–GitHub Release–npm chain has been verified against public metadata. The highest-value remaining evidence work is paired product evaluation across more real projects and repeating the same provenance verification for each new release. Missing external projects are reported as `insufficient_evidence`, never as a pass. None of these items should weaken the user-review gate or convert heuristic warnings into approval.
@@ -0,0 +1,21 @@
1
+ # MotionLoom 2.5.1
2
+
3
+ ## Summary
4
+
5
+ MotionLoom 2.5.1 is a hardening patch for the installed npm package, browser review boundary and runtime-output policy. It preserves the 2.5.0 capability surface while making the advertised onboarding, Dev Lab and runtime evidence paths work safely from an isolated consumer project.
6
+
7
+ ## Fixed
8
+
9
+ - The npm CLI preserves the caller's working directory, so default setup and onboarding target the consumer project instead of the installed package directory. The displayed CLI version is read from package metadata.
10
+ - The npm tarball includes the Dev Lab assets, runtime harness, Python requirements and optional browser-runtime dependencies required by its documented commands. Release validation installs the packed tarball into an isolated consumer project.
11
+ - Dev Lab renders manifest values through safe DOM APIs, enforces a same-origin script content-security policy and rejects invalid, expired or terminal browser-review candidates before review can be enabled.
12
+ - Runtime evidence cleanup is constrained to a dedicated child directory below an explicit policy root and cannot remove caller data outside that boundary.
13
+ - The protected GitHub Actions release uses npm Trusted Publishing/OIDC with a compatible pinned npm client instead of a long-lived publish token. npm generates public-package provenance automatically for the trusted workflow.
14
+
15
+ ## Verified behavior
16
+
17
+ The release candidate passes the full regression harness, quality and signed-attestation gate, isolated package-consumer smoke test, Dev Lab browser-security smoke test, real Rive/GSAP/Framer Motion runtime capture at 0/50/100%, a 6,900-case deep stress run, twelve Swift package tests, Skill Doctor, documentation audit, discovery, release verification and package inspection. The pull request carrying the implementation passed all twelve GitHub checks before merge.
18
+
19
+ ## Trust boundary
20
+
21
+ This patch does not add a framework capability, sign or distribute the Apple alpha, grant production approval or authorize a pull request or release from machine-generated evidence. Browser review remains identity-bound and human-governed. npm publication and GitHub Release creation remain explicit maintainer actions after the prepared commit is green on `main`.
@@ -1,6 +1,6 @@
1
1
  # Publish MotionLoom to npm from your workstation
2
2
 
3
- This guide publishes the prepared `motionloom@2.5.0` package from your own computer. The npm password, authenticator code and access token must stay on your computer; never paste them into chat, GitHub issues or repository files.
3
+ This guide publishes the prepared `motionloom@2.5.1` package from your own computer. The npm password, authenticator code and access token must stay on your computer; never paste them into chat, GitHub issues or repository files.
4
4
 
5
5
  ## 1. Install prerequisites
6
6
 
@@ -48,7 +48,7 @@ npm pack --dry-run --json --ignore-scripts
48
48
  npm publish --dry-run --access public
49
49
  ```
50
50
 
51
- The dry-run should report `motionloom@2.5.0`, public access, and the current package file list. Do not hard-code a historical file count: inspect the JSON output and compare it with the checked-in package allowlist. The prepack hook removes generated Python bytecode before packaging. Do not publish if the dry-run shows private keys, `.env` files, `artifacts/`, `dev-lab/` or `__pycache__/` entries.
51
+ The dry-run should report `motionloom@2.5.1`, public access, and the current package file list. Do not hard-code a historical file count: inspect the JSON output and compare it with the checked-in package allowlist. The prepack hook removes generated Python bytecode before packaging. Do not publish if the dry-run shows private keys, `.env` files, `artifacts/`, `dev-lab/` or `__pycache__/` entries.
52
52
 
53
53
  ## 5. Publish the package
54
54
 
@@ -60,15 +60,15 @@ npm publish --access public
60
60
 
61
61
  Do not add `--provenance` unless the local npm client and its CI/provider integration explicitly support automatic npm provenance. Some workstation setups report `Automatic provenance generation not supported for provider: null`; in that case the package itself is still valid, and the supported fallback for this release is the command above without `--provenance`. Never retry by changing the version or by bypassing the package verification steps.
62
62
 
63
- The version `2.5.0` becomes immutable on npm after a successful publish. If npm reports that the version already exists, stop and verify the registry instead of trying to overwrite it.
63
+ The version `2.5.1` becomes immutable on npm after a successful publish. If npm reports that the version already exists, stop and verify the registry instead of trying to overwrite it.
64
64
 
65
65
  ## 6. Verify the registry publication
66
66
 
67
- Run the following commands and confirm that they return `2.5.0` and a tarball URL:
67
+ Run the following commands and confirm that they return `2.5.1` and a tarball URL:
68
68
 
69
69
  ```bash
70
70
  npm view motionloom version --registry=https://registry.npmjs.org/
71
- npm view motionloom@2.5.0 name version license dist.tarball dist.shasum --json \
71
+ npm view motionloom@2.5.1 name version license dist.tarball dist.shasum --json \
72
72
  --registry=https://registry.npmjs.org/
73
73
  ```
74
74
 
@@ -78,7 +78,7 @@ Then test installation in a clean temporary directory:
78
78
  TMP_DIR="$(mktemp -d)"
79
79
  cd "$TMP_DIR"
80
80
  npm init --yes
81
- npm install motionloom@2.5.0
81
+ npm install motionloom@2.5.1
82
82
  motionloom --help
83
83
  motionloom doctor
84
84
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "motionloom",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@11.20.0",
6
6
  "description": "MotionLoom: project-aware animation Skill with Agent interoperability, graph, provenance, Motion IR, runtime telemetry, Visual Truth, Remediation Learning, DSSE-compatible signed attestation, external evidence verification and Dev Lab review.",
@@ -33,6 +33,10 @@
33
33
  "schemas/asset-provenance.schema.json",
34
34
  "examples/agent-consumer/ai-generated-pilot-provenance.json",
35
35
  "project-context.example.json",
36
+ "requirements.txt",
37
+ "dev-lab/public/index.html",
38
+ "dev-lab/public/devlab.js",
39
+ "tests/runtime-harness",
36
40
  "tests/evals",
37
41
  "tests/scripts/run_tests.py",
38
42
  "tests/scripts/test_attestation.py",
@@ -124,7 +128,7 @@
124
128
  },
125
129
  "scripts": {
126
130
  "prepack": "node scripts/prepack-clean.mjs",
127
- "test": "node bin/motionloom.mjs test",
131
+ "test": "python3 tests/scripts/run_tests.py",
128
132
  "validate": "npm run quality:attestation",
129
133
  "doctor": "node bin/motionloom.mjs doctor --json",
130
134
  "init": "node bin/motionloom.mjs init",
@@ -147,7 +151,8 @@
147
151
  "intelligence:p1": "node bin/motionloom.mjs intelligence",
148
152
  "quality:p1": "node bin/motionloom.mjs quality-gate --require-intelligence --require-p1 --require-benchmark",
149
153
  "eval:intelligence": "node bin/motionloom.mjs eval-intelligence",
150
- "audit:deep": "node bin/motionloom.mjs deep-audit --iterations 5000 --seed 20260813 --report docs/audits/data/deep-stress-latest.json",
154
+ "audit:deep": "python3 tests/scripts/deep-stress.py --iterations 5000 --seed 20260813 --report docs/audits/data/deep-stress-latest.json",
155
+ "package:smoke": "node scripts/package-consumer-smoke.mjs",
151
156
  "memory:init": "node bin/motionloom.mjs memory init --project-root .",
152
157
  "memory:bootstrap": "node bin/motionloom.mjs analyze . --init-memory",
153
158
  "memory:recover": "node bin/motionloom.mjs memory recover --project-root .",
@@ -191,9 +196,10 @@
191
196
  "react": "^19.2.8",
192
197
  "react-dom": "^19.2.8"
193
198
  },
194
- "devDependencies": {
199
+ "optionalDependencies": {
195
200
  "playwright": "^1.62.1",
196
201
  "vite": "^8.2.1"
197
202
  },
203
+ "devDependencies": {},
198
204
  "license": "MIT"
199
205
  }
@@ -0,0 +1 @@
1
+ cryptography>=42.0,<47.0
@@ -16,7 +16,7 @@
16
16
  "target": "rive",
17
17
  "framework": "rive",
18
18
  "status": "project_integrated",
19
- "evidence": [{"path": "scripts/runtime-adapters.mjs", "sha256": "711a64c07b4d82f646d066c7b0d91deaefc5c9c0fb701358706bf31d558b4d89"}],
19
+ "evidence": [{"path": "scripts/runtime-adapters.mjs", "sha256": "6f135e75e700a7bb105903db59445fcd3577669b595082a2b542db639f5fe56d"}],
20
20
  "limitations": ["Runtime harness evidence must be captured for the concrete scene and source bytes."]
21
21
  },
22
22
  {
@@ -24,7 +24,7 @@
24
24
  "target": "gsap",
25
25
  "framework": "gsap",
26
26
  "status": "project_integrated",
27
- "evidence": [{"path": "scripts/runtime-adapters.mjs", "sha256": "711a64c07b4d82f646d066c7b0d91deaefc5c9c0fb701358706bf31d558b4d89"}],
27
+ "evidence": [{"path": "scripts/runtime-adapters.mjs", "sha256": "6f135e75e700a7bb105903db59445fcd3577669b595082a2b542db639f5fe56d"}],
28
28
  "limitations": ["Runtime harness evidence must be captured for the concrete scene and source bytes."]
29
29
  },
30
30
  {
@@ -32,7 +32,7 @@
32
32
  "target": "framer-motion",
33
33
  "framework": "framer-motion",
34
34
  "status": "project_integrated",
35
- "evidence": [{"path": "scripts/runtime-adapters.mjs", "sha256": "711a64c07b4d82f646d066c7b0d91deaefc5c9c0fb701358706bf31d558b4d89"}],
35
+ "evidence": [{"path": "scripts/runtime-adapters.mjs", "sha256": "6f135e75e700a7bb105903db59445fcd3577669b595082a2b542db639f5fe56d"}],
36
36
  "limitations": ["Runtime harness evidence must be captured for the concrete scene and source bytes."]
37
37
  }
38
38
  ]
@@ -47,9 +47,15 @@ def main() -> int:
47
47
  print("capture-runtime-telemetry: --max-age-days must be non-negative", file=sys.stderr)
48
48
  return 2
49
49
 
50
- root = Path(__file__).resolve().parents[1]
50
+ package_root = Path(__file__).resolve().parents[1]
51
+ root = Path(os.environ.get("MOTIONLOOM_PROJECT_ROOT") or Path.cwd()).expanduser().resolve()
51
52
  task_dir = Path(args.task_dir).expanduser()
52
53
  task_dir = task_dir.resolve() if task_dir.is_absolute() else (root / task_dir).resolve()
54
+ try:
55
+ task_dir.relative_to(root)
56
+ except ValueError:
57
+ print("capture-runtime-telemetry: task directory must remain inside the project root", file=sys.stderr)
58
+ return 2
53
59
  scene_dir = (root / "src" / "output" / args.scene).resolve()
54
60
  manifest_path = scene_dir / "manifest.json"
55
61
  task_path = task_dir / "task.json"
@@ -74,10 +80,6 @@ def main() -> int:
74
80
  return 2
75
81
 
76
82
  output_dir = task_dir / "runtime-adapters"
77
- if output_dir.exists():
78
- shutil.rmtree(output_dir)
79
- output_dir.mkdir(parents=True, exist_ok=True)
80
-
81
83
  env = os.environ.copy()
82
84
  env.update(
83
85
  {
@@ -87,17 +89,23 @@ def main() -> int:
87
89
  "RUNTIME_SOURCE_PATH": str(source_path),
88
90
  "RUNTIME_MANIFEST_PATH": str(manifest_path),
89
91
  "RUNTIME_MOTION_IR_PATH": str(task_dir / "motion-ir.json"),
92
+ "MOTIONLOOM_RUNTIME_OUTPUT_ROOT": str(root),
90
93
  }
91
94
  )
92
- npm = "npm.cmd" if os.name == "nt" else "npm"
93
- runtime = subprocess.run([npm, "run", "runtime:test"], cwd=root, env=env, check=False)
95
+ node = shutil.which("node") or ("node.exe" if os.name == "nt" else "node")
96
+ runtime = subprocess.run(
97
+ [node, str(package_root / "scripts" / "runtime-adapters.mjs")],
98
+ cwd=root,
99
+ env=env,
100
+ check=False,
101
+ )
94
102
  if runtime.returncode != 0:
95
103
  return runtime.returncode
96
104
 
97
105
  verifier = subprocess.run(
98
106
  [
99
107
  sys.executable,
100
- str(root / "scripts" / "evidence-verifier.py"),
108
+ str(package_root / "scripts" / "evidence-verifier.py"),
101
109
  "--scene-dir",
102
110
  str(scene_dir),
103
111
  "--task-dir",
package/scripts/devlab.py CHANGED
@@ -87,17 +87,9 @@ def prepare_scene(root: Path, lab: Path, scene: str, task_dir: Path | None) -> t
87
87
  return destination, task_destination, task_id
88
88
 
89
89
 
90
- def pnpm_executable() -> str:
91
- candidate = "pnpm.cmd" if os.name == "nt" else "pnpm"
92
- return shutil.which(candidate) or candidate
93
-
94
-
95
90
  def serve(lab: Path, port: int) -> int:
96
91
  if not (lab / "public").is_dir():
97
92
  fail(f"Dev Lab public directory not found: {lab / 'public'}")
98
- if not (lab / "node_modules").is_dir():
99
- print("== installing Dev Lab dependencies (first run) ==")
100
- subprocess.run([pnpm_executable(), "install", "--silent"], cwd=lab, check=True)
101
93
  print(f"== Dev Lab ready: http://localhost:{port}/ ==")
102
94
  return subprocess.run([sys.executable, "-m", "http.server", str(port), "--directory", str(lab / "public")], check=False).returncode
103
95
 
@@ -110,8 +102,9 @@ def main() -> int:
110
102
  parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", "3300")))
111
103
  parser.add_argument("--prepare-only", action="store_true")
112
104
  args = parser.parse_args()
113
- root = Path(__file__).resolve().parents[1]
114
- lab = Path(args.lab_dir or os.environ.get("MOTIONLOOM_DEV_LAB") or root / "dev-lab").expanduser().resolve()
105
+ package_root = Path(__file__).resolve().parents[1]
106
+ root = Path(os.environ.get("MOTIONLOOM_PROJECT_ROOT") or Path.cwd()).expanduser().resolve()
107
+ lab = Path(args.lab_dir or os.environ.get("MOTIONLOOM_DEV_LAB") or package_root / "dev-lab").expanduser().resolve()
115
108
  task_dir = Path(args.task_dir).expanduser().resolve() if args.task_dir else None
116
109
  destination, task_destination, task_id = prepare_scene(root, lab, args.scene, task_dir)
117
110
  print(f"== Dev Lab scene prepared: {destination} ==")
@@ -93,9 +93,21 @@ if "--require-asset-provenance" not in (workflow_dir / "quality.yml").read_text(
93
93
  errors.append(f"{workflow.relative_to(ROOT)}: secrets referenced in pull_request workflow")
94
94
 
95
95
  release = (workflow_dir / "release.yml").read_text(encoding="utf-8")
96
- for required in ["workflow_dispatch:", "environment: npm-release", "id-token: write", "release_version:", "scripts/release-verify.py"]:
96
+ for required in [
97
+ "workflow_dispatch:",
98
+ "environment: npm-release",
99
+ "id-token: write",
100
+ "release_version:",
101
+ "scripts/release-verify.py",
102
+ 'node-version: "24"',
103
+ "npm@11.19.0",
104
+ 'npm publish --access public --tag "$NPM_TAG"',
105
+ ]:
97
106
  if required not in release:
98
107
  errors.append(f"release.yml: missing release safety control {required}")
108
+ for forbidden in ["NODE_AUTH_TOKEN", "secrets.NPM_TOKEN", "--provenance"]:
109
+ if forbidden in release:
110
+ errors.append(f"release.yml: token-based or redundant trusted-publishing control remains: {forbidden}")
99
111
 
100
112
  devlab = (workflow_dir / "devlab.yml").read_text(encoding="utf-8")
101
113
  for required in [
@@ -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
+ }
@@ -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>
@@ -0,0 +1,111 @@
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { gsap } from "gsap";
4
+ import { Rive } from "@rive-app/canvas";
5
+ import { FramerRuntimePilot } from "../../src/output/runtime-pilot-framer/scene.jsx";
6
+
7
+ const params = new URLSearchParams(window.location.search);
8
+ const framework = params.get("framework") || "gsap";
9
+ const statusNode = document.getElementById("status");
10
+ const rootNode = document.getElementById("root");
11
+
12
+ function status(text) {
13
+ if (statusNode) statusNode.textContent = text;
14
+ }
15
+
16
+ function expose(adapter) {
17
+ window.__animationAdapter = adapter;
18
+ status(`${adapter.framework}: ${adapter.status}`);
19
+ }
20
+
21
+ function mountGsap() {
22
+ const stage = document.createElement("div");
23
+ stage.className = "stage";
24
+ const box = document.createElement("div");
25
+ box.className = "gsap-box";
26
+ stage.appendChild(box);
27
+ rootNode.replaceChildren(stage);
28
+ const timeline = gsap.timeline({ paused: true });
29
+ timeline.fromTo(
30
+ box,
31
+ { x: 0, y: 0, rotation: 0, opacity: 0.25, scale: 0.82 },
32
+ { x: 240, y: -36, rotation: 28, opacity: 1, scale: 1, duration: 1, ease: "power2.out" },
33
+ );
34
+ const adapter = {
35
+ framework: "gsap",
36
+ runtime: `gsap@${gsap.version}`,
37
+ status: "ready",
38
+ ready: true,
39
+ setProgress(value) { timeline.progress(Math.max(0, Math.min(1, Number(value)))); },
40
+ getState() { return { progress: timeline.progress(), transform: getComputedStyle(box).transform, opacity: getComputedStyle(box).opacity }; },
41
+ };
42
+ expose(adapter);
43
+ adapter.setProgress(0);
44
+ }
45
+
46
+ async function mountRive() {
47
+ const canvas = document.createElement("canvas");
48
+ canvas.width = 420;
49
+ canvas.height = 420;
50
+ rootNode.replaceChildren(canvas);
51
+ let riveInstance;
52
+ const bytes = await fetch("/assets/library/rive/state-machine-test.riv").then((response) => {
53
+ if (!response.ok) throw new Error(`Rive fixture HTTP ${response.status}`);
54
+ return response.arrayBuffer();
55
+ });
56
+ await new Promise((resolve, reject) => {
57
+ riveInstance = new Rive({
58
+ buffer: bytes,
59
+ canvas,
60
+ stateMachines: "StateMachine",
61
+ autoplay: true,
62
+ autoBind: false,
63
+ onLoad: resolve,
64
+ onLoadError: reject,
65
+ });
66
+ });
67
+ riveInstance.resizeDrawingSurfaceToCanvas();
68
+ riveInstance.pause();
69
+ const inputs = riveInstance.stateMachineInputs("StateMachine") || [];
70
+ const byName = Object.fromEntries(inputs.map((input) => [input.name, input]));
71
+ if (!byName.MyNum || !byName.MyBool || !byName.MyTrig) {
72
+ throw new Error(`Rive StateMachine inputs missing: ${inputs.map((input) => input.name).join(", ")}`);
73
+ }
74
+ const adapter = {
75
+ framework: "rive",
76
+ runtime: "@rive-app/canvas@2.39.2",
77
+ status: "ready",
78
+ ready: true,
79
+ setProgress(value) {
80
+ const progress = Math.max(0, Math.min(1, Number(value)));
81
+ byName.MyNum.value = progress * 12;
82
+ byName.MyBool.value = progress >= 0.5;
83
+ if (progress > 0) byName.MyTrig.fire();
84
+ if (typeof riveInstance.advance === "function") riveInstance.advance(1 / 60);
85
+ },
86
+ getState() {
87
+ return {
88
+ inputs: inputs.map((input) => ({ name: input.name, type: input.type, value: input.value })),
89
+ stateMachines: riveInstance.playingStateMachineNames,
90
+ loaded: riveInstance.loaded,
91
+ };
92
+ },
93
+ dispose() { riveInstance.cleanup(); },
94
+ };
95
+ expose(adapter);
96
+ adapter.setProgress(0);
97
+ }
98
+
99
+ async function main() {
100
+ try {
101
+ if (framework === "gsap") mountGsap();
102
+ else if (framework === "framer-motion") createRoot(rootNode).render(<FramerRuntimePilot expose={expose} />);
103
+ else if (framework === "rive") await mountRive();
104
+ else throw new Error(`Unknown framework: ${framework}`);
105
+ } catch (error) {
106
+ const message = error instanceof Error ? error.message : String(error);
107
+ expose({ framework, status: "error", ready: false, error: message, getState: () => ({ error: message }) });
108
+ }
109
+ }
110
+
111
+ main();
@@ -402,6 +402,47 @@ def test_task_bundle_resolver_contract():
402
402
  )
403
403
  check("runtime adapter rejects unsupported framework path", unsafe_runtime.returncode != 0 and "unsupported" in (unsafe_runtime.stderr + unsafe_runtime.stdout))
404
404
 
405
+ with tempfile.TemporaryDirectory() as td:
406
+ outside = Path(td) / "protected-output"
407
+ outside.mkdir()
408
+ sentinel = outside / "keep.txt"
409
+ sentinel.write_text("do not delete", encoding="utf-8")
410
+ unsafe_output = subprocess.run(
411
+ ["node", str(ROOT / "scripts/runtime-adapters.mjs")],
412
+ env={**os.environ, "RUNTIME_FRAMEWORKS": "rive", "RUNTIME_EVIDENCE_DIR": str(outside)},
413
+ capture_output=True,
414
+ text=True,
415
+ )
416
+ check(
417
+ "runtime adapter rejects destructive output outside policy root",
418
+ unsafe_output.returncode != 0
419
+ and "MOTIONLOOM_RUNTIME_OUTPUT_ROOT" in (unsafe_output.stderr + unsafe_output.stdout)
420
+ and sentinel.read_text(encoding="utf-8") == "do not delete",
421
+ )
422
+
423
+ with tempfile.TemporaryDirectory() as td:
424
+ outside_task = Path(td) / "protected-task"
425
+ outside_task.mkdir()
426
+ sentinel = outside_task / "keep.txt"
427
+ sentinel.write_text("do not delete", encoding="utf-8")
428
+ unsafe_capture = subprocess.run(
429
+ [
430
+ sys.executable,
431
+ str(ROOT / "scripts/capture-runtime-telemetry.py"),
432
+ "browser-review-smoke",
433
+ str(outside_task),
434
+ ],
435
+ cwd=ROOT,
436
+ capture_output=True,
437
+ text=True,
438
+ )
439
+ check(
440
+ "runtime telemetry rejects task output outside project before cleanup",
441
+ unsafe_capture.returncode != 0
442
+ and "inside the project root" in (unsafe_capture.stderr + unsafe_capture.stdout)
443
+ and sentinel.read_text(encoding="utf-8") == "do not delete",
444
+ )
445
+
405
446
 
406
447
  def test_runtime_telemetry_verifier_contract():
407
448
  with tempfile.TemporaryDirectory() as td: