motionloom 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -0
  3. package/SKILL.md +122 -0
  4. package/agent-card.json +161 -0
  5. package/assets/library/ATTRIBUTION.md +6 -0
  6. package/assets/library/README.md +20 -0
  7. package/assets/library/avatar-base.svg +19 -0
  8. package/assets/library/error-alert.json +1 -0
  9. package/assets/library/rive/ATTRIBUTION.md +12 -0
  10. package/assets/library/rive/state-machine-test.riv +0 -0
  11. package/assets/library/success-check.json +1 -0
  12. package/bin/motionloom.mjs +81 -0
  13. package/docs/BROWSER-REVIEW-E2E.md +78 -0
  14. package/docs/CATEGORIES.md +16 -0
  15. package/docs/CHECKLIST.md +31 -0
  16. package/docs/DEEP-AUDIT-WORKING-NOTES.md +22 -0
  17. package/docs/FRAMEWORK-SELECTION.md +26 -0
  18. package/docs/PROJECT-MANIFEST.md +37 -0
  19. package/docs/ROADMAP-INTELLIGENCE.md +224 -0
  20. package/docs/audits/1.10.0-attestation-research-notes.md +19 -0
  21. package/docs/audits/1.8.0-trust-boundary-hardening.md +56 -0
  22. package/docs/audits/1.9.0-evidence-interoperability-threat-model.md +37 -0
  23. package/docs/audits/2.0.0-attestation-acceptance.md +30 -0
  24. package/docs/releases/1.5.0.md +25 -0
  25. package/docs/releases/1.6.0.md +27 -0
  26. package/docs/releases/1.7.0.md +23 -0
  27. package/docs/releases/1.8.0.md +23 -0
  28. package/docs/releases/1.9.0.md +21 -0
  29. package/docs/releases/2.0.0.md +21 -0
  30. package/docs/releases/npm-publish-from-workstation.md +88 -0
  31. package/docs/research/AGENT-PROTOCOL-FINDINGS.md +43 -0
  32. package/examples/report-demo/REPORT.md +50 -0
  33. package/examples/report-demo/artifact-manifest.json +25 -0
  34. package/examples/report-demo/decision-log.jsonl +0 -0
  35. package/examples/report-demo/execution-report.json +70 -0
  36. package/examples/report-demo/handoff.json +22 -0
  37. package/examples/report-demo/issue-register.json +5 -0
  38. package/examples/report-demo/task.json +13 -0
  39. package/package.json +95 -0
  40. package/project-context.example.json +26 -0
  41. package/references/browser-review-contract.md +32 -0
  42. package/references/dotlottie-source-notes.md +21 -0
  43. package/references/intelligence-core.md +98 -0
  44. package/references/reporting-contract.md +38 -0
  45. package/references/runtime-capability.md +12 -0
  46. package/references/signed-attestation.md +31 -0
  47. package/schemas/artifact-manifest.schema.json +22 -0
  48. package/schemas/browser-review-candidate.schema.json +24 -0
  49. package/schemas/capability-registry.schema.json +61 -0
  50. package/schemas/continuity-report.schema.json +52 -0
  51. package/schemas/evidence-verifier-report.schema.json +35 -0
  52. package/schemas/execution-report.schema.json +35 -0
  53. package/schemas/fix-plan.schema.json +44 -0
  54. package/schemas/handoff.schema.json +19 -0
  55. package/schemas/motion-ir.schema.json +74 -0
  56. package/schemas/project-graph.schema.json +70 -0
  57. package/schemas/provenance.schema.json +76 -0
  58. package/schemas/runtime-evidence.schema.json +48 -0
  59. package/schemas/runtime-telemetry.schema.json +50 -0
  60. package/schemas/scene-manifest.schema.json +45 -0
  61. package/schemas/semantic-benchmark.schema.json +26 -0
  62. package/schemas/semantic-lint-report.schema.json +48 -0
  63. package/schemas/signed-attestation.schema.json +95 -0
  64. package/schemas/task.schema.json +39 -0
  65. package/schemas/trust-policy.schema.json +53 -0
  66. package/scripts/analyze.sh +13 -0
  67. package/scripts/attestation-keygen.py +63 -0
  68. package/scripts/attestation-verifier.py +178 -0
  69. package/scripts/attestation.py +288 -0
  70. package/scripts/capture-runtime-telemetry.sh +37 -0
  71. package/scripts/devlab.sh +77 -0
  72. package/scripts/eval-intelligence.py +377 -0
  73. package/scripts/evidence-verifier.py +222 -0
  74. package/scripts/fetch-library.sh +57 -0
  75. package/scripts/intelligence.py +1543 -0
  76. package/scripts/manifest.py +61 -0
  77. package/scripts/pr.sh +103 -0
  78. package/scripts/quality-gate.py +378 -0
  79. package/scripts/render-node.mjs +53 -0
  80. package/scripts/render.sh +37 -0
  81. package/scripts/report-contract.py +181 -0
  82. package/scripts/report.py +588 -0
  83. package/scripts/review-hook.py +199 -0
  84. package/scripts/runtime-adapters.mjs +187 -0
  85. package/scripts/skill-doctor.py +150 -0
  86. package/scripts/to-dotlottie.mjs +99 -0
  87. package/scripts/to-dotlottie.sh +25 -0
  88. package/scripts/validate-lottie.py +102 -0
  89. package/src/core/analyzer.py +226 -0
  90. package/src/core/snapshot.py +124 -0
  91. package/src/core/spec.py +240 -0
  92. package/src/output/browser-review-smoke/animation.json +57 -0
  93. package/src/output/browser-review-smoke/browser-review.json +21 -0
  94. package/src/output/browser-review-smoke/manifest.json +22 -0
  95. package/src/output/browser-review-smoke/motion-spec.json +28 -0
  96. package/src/output/browser-review-smoke/snapshot/.render-meta.json +10 -0
  97. package/src/output/browser-review-smoke/snapshot/frame-00.png +0 -0
  98. package/src/output/browser-review-smoke/snapshot/frame-100.png +0 -0
  99. package/src/output/browser-review-smoke/snapshot/frame-50.png +0 -0
  100. package/src/rig/README.md +35 -0
  101. package/src/rig/cutout_rig.py +211 -0
  102. package/templates/framer-motion/ui-micro.tsx +50 -0
  103. package/templates/gsap/scroll-scene.js +54 -0
  104. package/templates/lottie/README.md +21 -0
  105. package/templates/lottie/react-component.tsx +82 -0
  106. package/templates/lottie/scaffold/animation.json +57 -0
  107. package/templates/lottie/scaffold/character-rig.svg +19 -0
  108. package/templates/lottie/vanilla.js +68 -0
  109. package/templates/rive/README.md +36 -0
  110. package/tests/evals/intelligence-cases.json +131 -0
  111. package/tests/scripts/run_tests.py +843 -0
  112. package/tests/scripts/test_attestation.py +172 -0
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env python3
2
+ """Prepare and validate the mandatory internal-browser Dev Lab review handoff."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import re
10
+ import subprocess
11
+ import sys
12
+ from datetime import datetime, timedelta, timezone
13
+ from pathlib import Path
14
+ from urllib.parse import urlencode
15
+
16
+ ROOT = Path(__file__).resolve().parents[1]
17
+ SAFE_SCENE = re.compile(r"^[A-Za-z0-9._-]+$")
18
+ CANDIDATE_TTL = timedelta(hours=24)
19
+
20
+
21
+ def now() -> str:
22
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
23
+
24
+
25
+ def read_json(path: Path, default: dict | None = None) -> dict:
26
+ if not path.is_file():
27
+ return {} if default is None else default
28
+ return json.loads(path.read_text(encoding="utf-8"))
29
+
30
+
31
+ def sha256(path: Path) -> str:
32
+ return hashlib.sha256(path.read_bytes()).hexdigest()
33
+
34
+
35
+ def parse_time(value: str) -> datetime:
36
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
37
+
38
+
39
+ def candidate_id(task_id: str, scene: str, context_hash: str, source_hash: str, render_hash: str) -> str:
40
+ raw = f"{task_id}:{scene}:{context_hash}:{source_hash}:{render_hash}".encode()
41
+ return hashlib.sha256(raw).hexdigest()[:20]
42
+
43
+
44
+ def paths(task_dir: Path, task: dict) -> tuple[Path, Path, Path, Path]:
45
+ scene = task.get("scene", "")
46
+ if not scene or scene in {".", ".."} or not SAFE_SCENE.fullmatch(scene):
47
+ raise ValueError("task.scene contains unsafe path characters")
48
+ scene_dir = ROOT / "src" / "output" / scene
49
+ manifest_path = scene_dir / "manifest.json"
50
+ manifest = read_json(manifest_path)
51
+ source_path = (scene_dir / manifest["file"]).resolve()
52
+ render_meta = scene_dir / "snapshot" / ".render-meta.json"
53
+ context_path = Path(task.get("context_path") or ROOT / "project-context.json")
54
+ if not context_path.is_absolute():
55
+ context_path = ROOT / context_path
56
+ if not source_path.is_file() or scene_dir.resolve() not in source_path.parents:
57
+ raise ValueError("manifest.file must point to a source inside the selected scene directory")
58
+ if not render_meta.is_file():
59
+ raise ValueError("runtime snapshot metadata is required before browser review")
60
+ return scene_dir, source_path, render_meta, context_path
61
+
62
+
63
+ def prepare(args: argparse.Namespace) -> int:
64
+ task_dir = Path(args.task_dir).resolve()
65
+ task_path = task_dir / "task.json"
66
+ task = read_json(task_path)
67
+ scene = task.get("scene")
68
+ if not scene:
69
+ raise ValueError("task.json requires scene")
70
+ scene_dir, source_path, render_meta, context_path = paths(task_dir, task)
71
+ spec = read_json(scene_dir / "motion-spec.json")
72
+ context_hash = (spec.get("context_binding") or {}).get("context_sha256") or sha256(context_path)
73
+ source_hash = sha256(source_path)
74
+ render_hash = sha256(render_meta)
75
+ cid = candidate_id(task["task_id"], scene, context_hash, source_hash, render_hash)
76
+ base = args.lab_url.rstrip("/")
77
+ task_id = task["task_id"]
78
+ url = f"{base}/?{urlencode({'scene': scene, 'task_id': task_id, 'candidate_id': cid, 'artifact_base': f'{base}/scenes/{scene}', 'task_base': f'{base}/tasks/{task_id}'})}"
79
+ candidate = {
80
+ "schema_version": "1.0",
81
+ "candidate_id": cid,
82
+ "task_id": task["task_id"],
83
+ "scene": scene,
84
+ "url": url,
85
+ "status": "prepared",
86
+ "context_sha256": context_hash,
87
+ "source_sha256": source_hash,
88
+ "runtime": spec.get("framework", "unknown"),
89
+ "checkpoints": [0, 50, 100],
90
+ "review_artifact": "review.json",
91
+ "requires_user_approval": True,
92
+ "prepared_at": now(),
93
+ "expires_at": (datetime.now(timezone.utc) + CANDIDATE_TTL).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
94
+ }
95
+ (scene_dir / "browser-review.json").write_text(json.dumps(candidate, indent=2) + "\n", encoding="utf-8")
96
+ task["state"] = "review_required"
97
+ task["updated_at"] = now()
98
+ task["browser_review"] = {"required": True, "status": "prepared", "candidate_id": cid, "candidate_path": "browser-review.json", "review_artifact": "review.json"}
99
+ (task_dir / "browser-review.json").write_text(json.dumps(candidate, indent=2) + "\n", encoding="utf-8")
100
+ (task_dir / "task.json").write_text(json.dumps(task, indent=2) + "\n", encoding="utf-8")
101
+ report_path = task_dir / "execution-report.json"
102
+ report = read_json(report_path)
103
+ report["status"] = "review_required"
104
+ report["browser_review"] = [item for item in report.get("browser_review", []) if item.get("candidate_id") != cid]
105
+ 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."})
106
+ report["next_agent"] = [item for item in report.get("next_agent", []) if item.get("id") != "browser-review"]
107
+ 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."})
108
+ report["generated_at"] = now()
109
+ report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
110
+ handoff_path = task_dir / "handoff.json"
111
+ handoff = read_json(handoff_path)
112
+ 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, "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"]))})
113
+ handoff_path.write_text(json.dumps(handoff, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
114
+ subprocess.run(["bash", str(ROOT / "scripts/devlab.sh"), scene, "--prepare-only", str(task_dir)], check=True, capture_output=True, text=True)
115
+ subprocess.run([sys.executable, str(ROOT / "scripts/report.py"), "collect", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
116
+ 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))
117
+ return 0
118
+
119
+
120
+ def validate(args: argparse.Namespace) -> int:
121
+ task_dir = Path(args.task_dir).resolve()
122
+ task = read_json(task_dir / "task.json")
123
+ candidate = read_json(task_dir / "browser-review.json")
124
+ scene_dir, source_path, render_meta, context_path = paths(task_dir, task)
125
+ spec = read_json(scene_dir / "motion-spec.json")
126
+ context_hash = (spec.get("context_binding") or {}).get("context_sha256") or sha256(context_path)
127
+ expected = candidate_id(task["task_id"], task["scene"], context_hash, sha256(source_path), sha256(render_meta))
128
+ errors = []
129
+ if not candidate.get("expires_at"):
130
+ errors.append("browser-review candidate has no expiry")
131
+ else:
132
+ try:
133
+ if datetime.now(timezone.utc) > parse_time(candidate["expires_at"]):
134
+ errors.append("browser-review candidate has expired")
135
+ except (TypeError, ValueError):
136
+ errors.append("browser-review candidate expiry is invalid")
137
+ if candidate.get("candidate_id") != expected:
138
+ errors.append("candidate_id does not match task, context, source and runtime metadata")
139
+ if candidate.get("task_id") != task.get("task_id"):
140
+ errors.append("candidate task_id does not match task.json")
141
+ task_review = task.get("browser_review") or {}
142
+ if task_review.get("candidate_id") != candidate.get("candidate_id"):
143
+ errors.append("task browser_review candidate_id does not match browser-review.json")
144
+ if task_review.get("status") == "approved" and candidate.get("status") != "approved":
145
+ errors.append("task browser_review status claims approved but candidate is not approved")
146
+ if candidate.get("scene") != task.get("scene"):
147
+ errors.append("candidate scene does not match task scene")
148
+ if candidate.get("source_sha256") != sha256(source_path):
149
+ errors.append("candidate source_sha256 is stale")
150
+ if candidate.get("context_sha256") != context_hash:
151
+ errors.append("candidate context_sha256 is stale")
152
+ if candidate.get("status") not in {"prepared", "opened", "reviewed", "approved", "changes_requested", "expired"}:
153
+ errors.append(f"browser-review candidate status is invalid: {candidate.get('status')}")
154
+ review = read_json(task_dir / "review.json", {})
155
+ if review and review.get("candidate_id") != candidate.get("candidate_id"):
156
+ errors.append("review.json approves a different candidate")
157
+ if review:
158
+ if review.get("task_id") != task.get("task_id"):
159
+ errors.append("review.json task_id does not match task.json")
160
+ if not str(review.get("reviewer") or "").strip():
161
+ errors.append("review.json reviewer is required")
162
+ reviewed_at = review.get("reviewed_at")
163
+ if reviewed_at:
164
+ try:
165
+ if parse_time(reviewed_at) > parse_time(candidate["expires_at"]):
166
+ errors.append("review.json was recorded after candidate expiry")
167
+ except (TypeError, ValueError, KeyError):
168
+ errors.append("review.json reviewed_at or candidate expiry is invalid")
169
+ if args.require_approved:
170
+ if candidate.get("status") != "approved":
171
+ errors.append("browser-review candidate is not approved")
172
+ if not review or review.get("decision") != "approved":
173
+ errors.append("review.json decision must be approved")
174
+ status = "pass" if not errors else "fail"
175
+ 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))
176
+ return 0 if not errors else 1
177
+
178
+
179
+ def main() -> int:
180
+ parser = argparse.ArgumentParser(description=__doc__)
181
+ sub = parser.add_subparsers(dest="command", required=True)
182
+ p = sub.add_parser("prepare")
183
+ p.add_argument("--task-dir", required=True)
184
+ p.add_argument("--lab-url", default="http://127.0.0.1:3300")
185
+ p.set_defaults(func=prepare)
186
+ v = sub.add_parser("validate")
187
+ v.add_argument("--task-dir", required=True)
188
+ v.add_argument("--require-approved", action="store_true")
189
+ v.set_defaults(func=validate)
190
+ args = parser.parse_args()
191
+ try:
192
+ return args.func(args)
193
+ except (KeyError, FileNotFoundError, json.JSONDecodeError, ValueError, subprocess.CalledProcessError) as exc:
194
+ print(json.dumps({"status": "fail", "error": str(exc)}, ensure_ascii=False))
195
+ return 1
196
+
197
+
198
+ if __name__ == "__main__":
199
+ raise SystemExit(main())
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { spawn } from "node:child_process";
6
+ import { setTimeout as sleep } from "node:timers/promises";
7
+ import { chromium } from "playwright";
8
+ import crypto from "node:crypto";
9
+
10
+ const ROOT = path.resolve(new URL("..", import.meta.url).pathname);
11
+ const outputRoot = path.resolve(process.env.RUNTIME_EVIDENCE_DIR || path.join(ROOT, "artifacts/runtime-adapters"));
12
+ const port = Number(process.env.RUNTIME_HARNESS_PORT || 4179);
13
+ const supportedFrameworks = new Set(["rive", "gsap", "framer-motion"]);
14
+ const frameworks = (process.env.RUNTIME_FRAMEWORKS || "rive,gsap,framer-motion")
15
+ .split(",").map((name) => name.trim()).filter(Boolean);
16
+ const unsupported = frameworks.filter((name) => !supportedFrameworks.has(name));
17
+ if (unsupported.length) {
18
+ throw new Error(`unsupported runtime framework(s): ${unsupported.join(", ")}`);
19
+ }
20
+ if (outputRoot === ROOT || outputRoot === path.parse(outputRoot).root) {
21
+ throw new Error("RUNTIME_EVIDENCE_DIR must be a dedicated child output directory");
22
+ }
23
+ const baseUrl = `http://127.0.0.1:${port}`;
24
+ const runId = `${Date.now()}-${process.pid}`;
25
+ const runtimeScene = process.env.RUNTIME_SCENE || null;
26
+ const runtimeTaskId = process.env.RUNTIME_TASK_ID || null;
27
+ const runtimeSourcePath = process.env.RUNTIME_SOURCE_PATH ? path.resolve(process.env.RUNTIME_SOURCE_PATH) : null;
28
+ const runtimeManifestPath = process.env.RUNTIME_MANIFEST_PATH ? path.resolve(process.env.RUNTIME_MANIFEST_PATH) : null;
29
+ const runtimeMotionIrPath = process.env.RUNTIME_MOTION_IR_PATH ? path.resolve(process.env.RUNTIME_MOTION_IR_PATH) : null;
30
+
31
+ function sha256File(filePath) {
32
+ if (!filePath || !fs.existsSync(filePath)) return null;
33
+ return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
34
+ }
35
+
36
+ function sha256Json(value) {
37
+ return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
38
+ }
39
+
40
+ fs.rmSync(outputRoot, { recursive: true, force: true });
41
+ fs.mkdirSync(outputRoot, { recursive: true });
42
+ const viteBin = path.join(ROOT, "node_modules/vite/bin/vite.js");
43
+ const server = spawn(process.execPath, [viteBin, "--host", "127.0.0.1", "--port", String(port), "--strictPort"], {
44
+ cwd: ROOT,
45
+ stdio: ["ignore", "pipe", "pipe"],
46
+ });
47
+ let serverLog = "";
48
+ server.stdout.on("data", (chunk) => { serverLog += chunk.toString(); });
49
+ server.stderr.on("data", (chunk) => { serverLog += chunk.toString(); });
50
+
51
+ try {
52
+ await waitForServer(`${baseUrl}/tests/runtime-harness/index.html`);
53
+ const browser = await chromium.launch({ headless: true });
54
+ const summary = [];
55
+ for (const framework of frameworks) {
56
+ summary.push(await testFramework(browser, framework));
57
+ }
58
+ await browser.close();
59
+ const report = {
60
+ schema_version: "1.1",
61
+ run_id: runId,
62
+ generated_at: new Date().toISOString(),
63
+ mode: "runtime",
64
+ harness: "tests/runtime-harness",
65
+ status: summary.every((item) => item.status === "pass") ? "pass" : "fail",
66
+ frameworks: summary,
67
+ };
68
+ if (runtimeScene) report.scene = runtimeScene;
69
+ if (runtimeTaskId) report.task_id = runtimeTaskId;
70
+ if (runtimeSourcePath) report.source_sha256 = sha256File(runtimeSourcePath);
71
+ if (runtimeManifestPath) report.manifest_sha256 = sha256File(runtimeManifestPath);
72
+ if (runtimeMotionIrPath) report.motion_ir_sha256 = sha256File(runtimeMotionIrPath);
73
+ fs.writeFileSync(path.join(outputRoot, "runtime-evidence.json"), `${JSON.stringify(report, null, 2)}\n`);
74
+ const failed = summary.filter((item) => item.status !== "pass");
75
+ if (failed.length) {
76
+ console.error(JSON.stringify(report, null, 2));
77
+ process.exitCode = 1;
78
+ } else {
79
+ console.log(JSON.stringify(report, null, 2));
80
+ }
81
+ } finally {
82
+ server.kill("SIGTERM");
83
+ await sleep(100);
84
+ if (!server.killed) server.kill("SIGKILL");
85
+ }
86
+
87
+ async function waitForServer(url) {
88
+ for (let attempt = 0; attempt < 80; attempt += 1) {
89
+ try {
90
+ const response = await fetch(url);
91
+ if (response.ok) return;
92
+ } catch {
93
+ // Vite is still starting.
94
+ }
95
+ await sleep(100);
96
+ }
97
+ throw new Error(`runtime harness did not start on ${url}\n${serverLog}`);
98
+ }
99
+
100
+ async function testFramework(browser, framework) {
101
+ const sceneDir = path.join(outputRoot, framework);
102
+ fs.mkdirSync(sceneDir, { recursive: true });
103
+ const page = await browser.newPage({ viewport: { width: 512, height: 512 }, deviceScaleFactor: 1 });
104
+ const consoleErrors = [];
105
+ page.on("pageerror", (error) => consoleErrors.push(error.message));
106
+ page.on("console", (message) => {
107
+ if (message.type() === "error") consoleErrors.push(message.text());
108
+ });
109
+ const url = `${baseUrl}/tests/runtime-harness/index.html?framework=${encodeURIComponent(framework)}`;
110
+ const result = { run_id: runId, framework, url, status: "fail", ready: false, runtime: null, frames: [], console_errors: consoleErrors };
111
+ try {
112
+ await page.goto(url, { waitUntil: "networkidle" });
113
+ await page.waitForFunction(() => Boolean(window.__animationAdapter?.ready), null, { timeout: 30000 });
114
+ result.ready = true;
115
+ result.runtime = await page.evaluate(() => window.__animationAdapter.runtime);
116
+ const telemetrySamples = [];
117
+ for (const [sequence, percent] of [0, 50, 100].entries()) {
118
+ const timing = await page.evaluate(async (value) => {
119
+ window.__animationAdapter.setProgress(value / 100);
120
+ const timestamps = await new Promise((resolve) => {
121
+ const values = [];
122
+ const collect = (timestamp) => {
123
+ values.push(timestamp);
124
+ if (values.length >= 4) resolve(values);
125
+ else requestAnimationFrame(collect);
126
+ };
127
+ requestAnimationFrame(collect);
128
+ });
129
+ return {
130
+ captured_at_ms: performance.now(),
131
+ raf_intervals_ms: timestamps.slice(1).map((timestamp, index) => Math.max(0, timestamp - timestamps[index])),
132
+ };
133
+ }, percent);
134
+ const state = await page.evaluate(() => window.__animationAdapter.getState());
135
+ const file = path.join(sceneDir, `frame-${String(percent).padStart(2, "0")}.png`);
136
+ await page.screenshot({ path: file });
137
+ result.frames.push({ percent, file: path.relative(ROOT, file), state });
138
+ telemetrySamples.push({
139
+ sequence,
140
+ percent,
141
+ captured_at_ms: Number(timing.captured_at_ms),
142
+ raf_intervals_ms: timing.raf_intervals_ms.map(Number),
143
+ state_sha256: sha256Json(state),
144
+ state,
145
+ });
146
+ }
147
+ if (consoleErrors.length) throw new Error(consoleErrors.join("; "));
148
+ const intervals = telemetrySamples.flatMap((sample) => sample.raf_intervals_ms);
149
+ const sortedIntervals = [...intervals].sort((a, b) => a - b);
150
+ const p95Index = Math.min(sortedIntervals.length - 1, Math.max(0, Math.ceil(sortedIntervals.length * 0.95) - 1));
151
+ const telemetry = {
152
+ schema_version: "1.0",
153
+ run_id: runId,
154
+ generated_at: new Date().toISOString(),
155
+ mode: "runtime-telemetry",
156
+ ...(runtimeTaskId ? { task_id: runtimeTaskId } : {}),
157
+ ...(runtimeScene ? { scene: runtimeScene } : {}),
158
+ framework,
159
+ runtime: result.runtime,
160
+ ...(runtimeSourcePath ? { source_sha256: sha256File(runtimeSourcePath) } : {}),
161
+ ...(runtimeManifestPath ? { manifest_sha256: sha256File(runtimeManifestPath) } : {}),
162
+ ...(runtimeMotionIrPath ? { motion_ir_sha256: sha256File(runtimeMotionIrPath) } : {}),
163
+ samples: telemetrySamples,
164
+ metrics: {
165
+ sample_count: telemetrySamples.length,
166
+ raf_interval_count: intervals.length,
167
+ max_raf_interval_ms: Math.max(...intervals),
168
+ p95_raf_interval_ms: sortedIntervals[p95Index],
169
+ },
170
+ status: "pass",
171
+ };
172
+ const telemetryPath = path.join(sceneDir, "runtime-telemetry.json");
173
+ fs.writeFileSync(telemetryPath, `${JSON.stringify(telemetry, null, 2)}\n`);
174
+ result.telemetry = {
175
+ file: path.relative(outputRoot, telemetryPath),
176
+ sha256: sha256File(telemetryPath),
177
+ metrics: telemetry.metrics,
178
+ };
179
+ result.status = "pass";
180
+ } catch (error) {
181
+ result.error = error instanceof Error ? error.message : String(error);
182
+ } finally {
183
+ await page.close();
184
+ }
185
+ fs.writeFileSync(path.join(sceneDir, "runtime-evidence.json"), `${JSON.stringify(result, null, 2)}\n`);
186
+ return result;
187
+ }
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env python3
2
+ """Check the skill package structure and emit machine-readable diagnostics."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+
12
+
13
+ ROOT = Path(__file__).resolve().parents[1]
14
+ REQUIRED_FILES = ["SKILL.md", "agent-card.json", "package.json"]
15
+ REQUIRED_DIRS = ["scripts", "templates", "references", "schemas"]
16
+ REQUIRED_SCRIPT_FILES = ["scripts/report-contract.py", "scripts/review-hook.py", "scripts/quality-gate.py", "scripts/runtime-adapters.mjs"]
17
+ REQUIRED_SCHEMAS = [
18
+ "task.schema.json",
19
+ "execution-report.schema.json",
20
+ "artifact-manifest.schema.json",
21
+ "handoff.schema.json",
22
+ "browser-review-candidate.schema.json",
23
+ "scene-manifest.schema.json",
24
+ "runtime-evidence.schema.json",
25
+ "project-graph.schema.json",
26
+ "provenance.schema.json",
27
+ "capability-registry.schema.json",
28
+ "motion-ir.schema.json",
29
+ ]
30
+
31
+
32
+ def check(condition: bool, code: str, message: str, errors: list, warnings: list) -> None:
33
+ target = errors if not condition else warnings
34
+ if not condition:
35
+ errors.append({"code": code, "message": message})
36
+
37
+
38
+ def parse_frontmatter(text: str) -> dict[str, str] | None:
39
+ lines = text.splitlines()
40
+ if not lines or lines[0].strip() != "---":
41
+ return None
42
+ try:
43
+ end = lines.index("---", 1)
44
+ except ValueError:
45
+ return None
46
+ values: dict[str, str] = {}
47
+ for line in lines[1:end]:
48
+ match = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", line)
49
+ if match and not match.group(1).startswith(" "):
50
+ values[match.group(1)] = match.group(2).strip().strip('"')
51
+ return values
52
+
53
+
54
+ def run() -> int:
55
+ parser = argparse.ArgumentParser(description=__doc__)
56
+ parser.add_argument("--json", action="store_true", dest="as_json")
57
+ args = parser.parse_args()
58
+ errors: list[dict] = []
59
+ warnings: list[dict] = []
60
+ checks: list[dict] = []
61
+
62
+ for relative in REQUIRED_FILES:
63
+ exists = (ROOT / relative).is_file()
64
+ checks.append({"id": f"file:{relative}", "status": "pass" if exists else "fail"})
65
+ if not exists:
66
+ errors.append({"code": "missing_file", "message": f"Missing required file: {relative}"})
67
+ for relative in REQUIRED_DIRS:
68
+ exists = (ROOT / relative).is_dir()
69
+ checks.append({"id": f"directory:{relative}", "status": "pass" if exists else "fail"})
70
+ if not exists:
71
+ errors.append({"code": "missing_directory", "message": f"Missing required directory: {relative}"})
72
+ for relative in REQUIRED_SCRIPT_FILES:
73
+ exists = (ROOT / relative).is_file()
74
+ checks.append({"id": f"file:{relative}", "status": "pass" if exists else "fail"})
75
+ if not exists:
76
+ errors.append({"code": "missing_file", "message": f"Missing required runtime/contract script: {relative}"})
77
+
78
+ skill_path = ROOT / "SKILL.md"
79
+ if skill_path.is_file():
80
+ text = skill_path.read_text(encoding="utf-8")
81
+ frontmatter = parse_frontmatter(text)
82
+ valid_meta = bool(frontmatter and frontmatter.get("name") and frontmatter.get("description"))
83
+ checks.append({"id": "skill:frontmatter", "status": "pass" if valid_meta else "fail"})
84
+ if not valid_meta:
85
+ errors.append({"code": "invalid_frontmatter", "message": "SKILL.md needs YAML frontmatter with name and description."})
86
+ line_count = len(text.splitlines())
87
+ checks.append({"id": "skill:line_count", "status": "pass" if line_count <= 500 else "fail", "lines": line_count})
88
+ if line_count > 500:
89
+ errors.append({"code": "skill_too_long", "message": f"SKILL.md has {line_count} lines; keep the body under 500 lines."})
90
+
91
+ for relative in REQUIRED_SCHEMAS:
92
+ path = ROOT / "schemas" / relative
93
+ try:
94
+ json.loads(path.read_text(encoding="utf-8"))
95
+ checks.append({"id": f"schema:{relative}", "status": "pass"})
96
+ except (FileNotFoundError, json.JSONDecodeError) as exc:
97
+ checks.append({"id": f"schema:{relative}", "status": "fail"})
98
+ errors.append({"code": "invalid_schema", "message": f"{relative}: {exc}"})
99
+
100
+ card_path = ROOT / "agent-card.json"
101
+ try:
102
+ card = json.loads(card_path.read_text(encoding="utf-8"))
103
+ for field in ("name", "version", "capabilities", "input_artifacts", "output_artifacts", "runtime_capabilities", "side_effects"):
104
+ if field not in card:
105
+ errors.append({"code": "agent_card_field", "message": f"agent-card.json missing field: {field}"})
106
+ verified = set(card.get("runtime_capabilities", {}).get("verified", []))
107
+ scaffold = set(card.get("runtime_capabilities", {}).get("scaffold_only", []))
108
+ overlap = sorted(verified & scaffold)
109
+ if overlap:
110
+ errors.append({"code": "capability_overlap", "message": f"Runtime listed as verified and scaffold-only: {overlap}"})
111
+ except (FileNotFoundError, json.JSONDecodeError) as exc:
112
+ errors.append({"code": "invalid_agent_card", "message": str(exc)})
113
+
114
+ skill_text = skill_path.read_text(encoding="utf-8") if skill_path.is_file() else ""
115
+ for reference in re.findall(r"`(references/[A-Za-z0-9_./-]+)`", skill_text):
116
+ exists = (ROOT / reference).is_file()
117
+ checks.append({"id": f"reference:{reference}", "status": "pass" if exists else "fail"})
118
+ if not exists:
119
+ errors.append({"code": "broken_reference", "message": f"SKILL.md references missing file: {reference}"})
120
+
121
+ package_path = ROOT / "package.json"
122
+ try:
123
+ package = json.loads(package_path.read_text(encoding="utf-8"))
124
+ for script in ("test", "validate", "doctor", "report", "report:check", "review"):
125
+ if script not in package.get("scripts", {}):
126
+ warnings.append({"code": "missing_package_script", "message": f"package.json has no {script} script."})
127
+ except (FileNotFoundError, json.JSONDecodeError) as exc:
128
+ errors.append({"code": "invalid_package_json", "message": str(exc)})
129
+
130
+ result = {
131
+ "doctor_version": "1.0",
132
+ "skill_root": str(ROOT),
133
+ "status": "pass" if not errors else "fail",
134
+ "checks": checks,
135
+ "errors": errors,
136
+ "warnings": warnings,
137
+ }
138
+ if args.as_json:
139
+ print(json.dumps(result, indent=2, ensure_ascii=False))
140
+ else:
141
+ print(f"skill-doctor: {result['status'].upper()}")
142
+ for item in errors:
143
+ print(f"ERROR {item['code']}: {item['message']}")
144
+ for item in warnings:
145
+ print(f"WARN {item['code']}: {item['message']}")
146
+ return 0 if not errors else 1
147
+
148
+
149
+ if __name__ == "__main__":
150
+ sys.exit(run())
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deterministic dotLottie v2 packager.
4
+ * The scene manifest stays outside the archive; the archive contains the
5
+ * standard manifest.json plus the Lottie payload under a/.
6
+ */
7
+ import crypto from "node:crypto";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { execFileSync } from "node:child_process";
12
+
13
+ function arg(name, fallback = undefined) {
14
+ const index = process.argv.indexOf(name);
15
+ return index === -1 ? fallback : process.argv[index + 1];
16
+ }
17
+
18
+ const sceneDir = path.resolve(arg("--scene-dir", ""));
19
+ const output = path.resolve(arg("--output", ""));
20
+ const generator = arg("--generator", "motionloom/to-dotlottie");
21
+ if (!sceneDir || !output) throw new Error("--scene-dir and --output are required");
22
+
23
+ const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
24
+ const sceneManifestPath = path.join(sceneDir, "manifest.json");
25
+ if (!fs.existsSync(sceneManifestPath)) throw new Error("scene manifest.json is required");
26
+ const sceneManifest = readJson(sceneManifestPath);
27
+ const source = sceneManifest.file;
28
+ if (typeof source !== "string" || !source || path.isAbsolute(source) || source.includes("..")) {
29
+ throw new Error("scene manifest.file must be a relative path without traversal");
30
+ }
31
+ const sourcePath = path.resolve(sceneDir, source);
32
+ if (!sourcePath.startsWith(`${sceneDir}${path.sep}`) || !fs.existsSync(sourcePath)) {
33
+ throw new Error(`scene source does not exist inside scene directory: ${source}`);
34
+ }
35
+ if (path.extname(sourcePath).toLowerCase() !== ".json") {
36
+ throw new Error("dotLottie packager currently accepts a Lottie JSON scene source");
37
+ }
38
+
39
+ const animationId = String(arg("--animation-id", path.basename(sourcePath, ".json")))
40
+ .trim();
41
+ if (!/^[a-zA-Z0-9._ -]+$/.test(animationId)) {
42
+ throw new Error(`invalid dotLottie animation id: ${animationId}`);
43
+ }
44
+ readJson(sourcePath);
45
+
46
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "animation-skill-dotlottie-"));
47
+ const archiveRoot = path.join(tmp, "archive");
48
+ fs.mkdirSync(path.join(archiveRoot, "a"), { recursive: true });
49
+ fs.copyFileSync(sourcePath, path.join(archiveRoot, "a", `${animationId}.json`));
50
+
51
+ const optionalDirectories = ["i", "t", "s", "f"];
52
+ for (const directory of optionalDirectories) {
53
+ const candidate = path.join(sceneDir, "dotlottie", directory);
54
+ if (fs.existsSync(candidate)) {
55
+ fs.cpSync(candidate, path.join(archiveRoot, directory), { recursive: true });
56
+ }
57
+ }
58
+
59
+ const dotManifest = {
60
+ version: "2",
61
+ generator,
62
+ initial: { animation: animationId },
63
+ animations: [{ id: animationId }],
64
+ };
65
+ fs.writeFileSync(
66
+ path.join(archiveRoot, "manifest.json"),
67
+ `${JSON.stringify(dotManifest, null, 2)}\n`,
68
+ "utf8",
69
+ );
70
+
71
+ fs.mkdirSync(path.dirname(output), { recursive: true });
72
+ if (fs.existsSync(output)) fs.rmSync(output, { force: true });
73
+ execFileSync("zip", ["-X", "-q", "-r", output, "."], { cwd: archiveRoot });
74
+
75
+ const entries = execFileSync("unzip", ["-Z1", output], { encoding: "utf8" })
76
+ .trim()
77
+ .split(/\r?\n/)
78
+ .filter(Boolean);
79
+ if (!entries.includes("manifest.json")) throw new Error("archive missing manifest.json");
80
+ if (!entries.includes(`a/${animationId}.json`)) {
81
+ throw new Error("archive missing initial animation payload");
82
+ }
83
+ const packagedManifest = readJsonFromZip(output, "manifest.json");
84
+ if (packagedManifest.version !== "2") throw new Error("dotLottie manifest version must be 2");
85
+ if (packagedManifest.initial?.animation !== animationId) {
86
+ throw new Error("dotLottie initial.animation does not match packaged animation");
87
+ }
88
+ const hash = crypto.createHash("sha256").update(fs.readFileSync(output)).digest("hex");
89
+ console.log(JSON.stringify({
90
+ output,
91
+ bytes: fs.statSync(output).size,
92
+ sha256: hash,
93
+ manifest: packagedManifest,
94
+ entries,
95
+ }, null, 2));
96
+
97
+ function readJsonFromZip(file, entry) {
98
+ return JSON.parse(execFileSync("unzip", ["-p", file, entry], { encoding: "utf8" }));
99
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # Package a validated scene into a dotLottie v2 archive.
3
+ # Usage: bash scripts/to-dotlottie.sh <scene> [output.lottie]
4
+ set -euo pipefail
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ REPO="$(dirname "$SCRIPT_DIR")"
8
+ SCENE="${1:?usage: bash scripts/to-dotlottie.sh <scene> [output.lottie]}"
9
+ OUT="${2:-$REPO/src/output/$SCENE/animation.lottie}"
10
+
11
+ if [[ ! "$SCENE" =~ ^[A-Za-z0-9._-]+$ ]]; then
12
+ echo "error: scene id contains unsafe path characters: $SCENE" >&2
13
+ exit 1
14
+ fi
15
+
16
+ SCENE_DIR="$REPO/src/output/$SCENE"
17
+ if [[ ! -d "$SCENE_DIR" ]]; then
18
+ echo "error: scene directory not found: $SCENE_DIR" >&2
19
+ exit 1
20
+ fi
21
+
22
+ node "$SCRIPT_DIR/to-dotlottie.mjs" \
23
+ --scene-dir "$SCENE_DIR" \
24
+ --output "$OUT" \
25
+ --generator "motionloom/to-dotlottie@1.0.0"