motionloom 2.4.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 +41 -0
- package/README.md +22 -2
- package/SKILL.md +1 -1
- package/agent-card.json +4 -1
- package/agent-surfaces.json +1 -1
- package/bin/motionloom.mjs +20 -6
- package/capability-registry.json +308 -0
- package/dev-lab/public/devlab.js +193 -0
- package/dev-lab/public/index.html +30 -0
- package/docs/STATUS.md +2 -2
- package/docs/apple-distribution.md +24 -0
- package/docs/architecture/apple/cloudkit.md +18 -0
- package/docs/architecture/apple/contracts.md +9 -0
- package/docs/architecture/apple/implementation-status-2026-08-16.md +55 -0
- package/docs/architecture/apple/studio.md +35 -0
- package/docs/releases/2.5.0.md +31 -0
- package/docs/releases/2.5.1.md +21 -0
- package/docs/releases/npm-publish-from-workstation.md +6 -6
- package/docs/research/ai-repository-benchmark-2026-08-16.md +75 -0
- package/package.json +12 -4
- package/requirements.txt +1 -0
- package/rig-adapter-registry.json +3 -3
- package/scripts/attestation.py +8 -0
- package/scripts/capture-runtime-telemetry.py +16 -8
- package/scripts/devlab.py +3 -10
- package/scripts/docs-audit.py +13 -1
- package/scripts/evidence-verifier.py +18 -9
- package/scripts/intelligence.py +85 -18
- package/scripts/package-consumer-smoke.mjs +76 -0
- package/scripts/runtime-adapters.mjs +28 -3
- package/scripts/skill-doctor.py +27 -1
- package/tests/runtime-harness/index.html +24 -0
- package/tests/runtime-harness/main.jsx +111 -0
- package/tests/scripts/run_tests.py +85 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "motionloom",
|
|
3
|
-
"version": "2.
|
|
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.",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"SKILL.md",
|
|
20
20
|
"LICENSE",
|
|
21
21
|
"agent-card.json",
|
|
22
|
+
"capability-registry.json",
|
|
22
23
|
"bin",
|
|
23
24
|
"scripts",
|
|
24
25
|
"src",
|
|
@@ -32,6 +33,10 @@
|
|
|
32
33
|
"schemas/asset-provenance.schema.json",
|
|
33
34
|
"examples/agent-consumer/ai-generated-pilot-provenance.json",
|
|
34
35
|
"project-context.example.json",
|
|
36
|
+
"requirements.txt",
|
|
37
|
+
"dev-lab/public/index.html",
|
|
38
|
+
"dev-lab/public/devlab.js",
|
|
39
|
+
"tests/runtime-harness",
|
|
35
40
|
"tests/evals",
|
|
36
41
|
"tests/scripts/run_tests.py",
|
|
37
42
|
"tests/scripts/test_attestation.py",
|
|
@@ -123,7 +128,7 @@
|
|
|
123
128
|
},
|
|
124
129
|
"scripts": {
|
|
125
130
|
"prepack": "node scripts/prepack-clean.mjs",
|
|
126
|
-
"test": "
|
|
131
|
+
"test": "python3 tests/scripts/run_tests.py",
|
|
127
132
|
"validate": "npm run quality:attestation",
|
|
128
133
|
"doctor": "node bin/motionloom.mjs doctor --json",
|
|
129
134
|
"init": "node bin/motionloom.mjs init",
|
|
@@ -142,10 +147,12 @@
|
|
|
142
147
|
"intelligence": "node bin/motionloom.mjs intelligence",
|
|
143
148
|
"intelligence:registry": "node bin/motionloom.mjs intelligence capabilities build --output capability-registry.json",
|
|
144
149
|
"intelligence:registry:check": "node bin/motionloom.mjs intelligence capabilities validate --path capability-registry.json",
|
|
150
|
+
"intelligence:card": "node bin/motionloom.mjs capability card --format json",
|
|
145
151
|
"intelligence:p1": "node bin/motionloom.mjs intelligence",
|
|
146
152
|
"quality:p1": "node bin/motionloom.mjs quality-gate --require-intelligence --require-p1 --require-benchmark",
|
|
147
153
|
"eval:intelligence": "node bin/motionloom.mjs eval-intelligence",
|
|
148
|
-
"audit:deep": "
|
|
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",
|
|
149
156
|
"memory:init": "node bin/motionloom.mjs memory init --project-root .",
|
|
150
157
|
"memory:bootstrap": "node bin/motionloom.mjs analyze . --init-memory",
|
|
151
158
|
"memory:recover": "node bin/motionloom.mjs memory recover --project-root .",
|
|
@@ -189,9 +196,10 @@
|
|
|
189
196
|
"react": "^19.2.8",
|
|
190
197
|
"react-dom": "^19.2.8"
|
|
191
198
|
},
|
|
192
|
-
"
|
|
199
|
+
"optionalDependencies": {
|
|
193
200
|
"playwright": "^1.62.1",
|
|
194
201
|
"vite": "^8.2.1"
|
|
195
202
|
},
|
|
203
|
+
"devDependencies": {},
|
|
196
204
|
"license": "MIT"
|
|
197
205
|
}
|
package/requirements.txt
ADDED
|
@@ -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": "
|
|
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": "
|
|
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": "
|
|
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
|
]
|
package/scripts/attestation.py
CHANGED
|
@@ -41,6 +41,11 @@ def dsse_pae(payload_type: str, body: bytes) -> bytes:
|
|
|
41
41
|
return b"DSSEv1 " + str(len(payload_type_bytes)).encode("ascii") + b" " + payload_type_bytes + b" " + str(len(body)).encode("ascii") + b" " + body
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
def is_macos_var_alias(path: Path) -> bool:
|
|
45
|
+
"""Allow only macOS's documented lexical alias `/var` -> `/private/var`."""
|
|
46
|
+
return path == Path("/var") and path.is_symlink() and path.resolve() == Path("/private/var")
|
|
47
|
+
|
|
48
|
+
|
|
44
49
|
def reject_symlink_components(path: Path) -> None:
|
|
45
50
|
current = Path(path.anchor) if path.anchor else Path(".")
|
|
46
51
|
for part in path.parts:
|
|
@@ -48,6 +53,9 @@ def reject_symlink_components(path: Path) -> None:
|
|
|
48
53
|
continue
|
|
49
54
|
current = current / part
|
|
50
55
|
if current.is_symlink():
|
|
56
|
+
if is_macos_var_alias(current):
|
|
57
|
+
current = current.resolve()
|
|
58
|
+
continue
|
|
51
59
|
raise ValueError(f"symlink path component is not allowed: {path}")
|
|
52
60
|
|
|
53
61
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
93
|
-
runtime = subprocess.run(
|
|
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(
|
|
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
|
-
|
|
114
|
-
|
|
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} ==")
|
package/scripts/docs-audit.py
CHANGED
|
@@ -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 [
|
|
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 [
|
|
@@ -34,14 +34,21 @@ def parse_json(path: Path) -> object:
|
|
|
34
34
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
def
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
def reject_symlink_within_root(root: Path, relative: Path, label: str) -> None:
|
|
38
|
+
"""Reject links inside an untrusted root, not symlinked system parents.
|
|
39
|
+
|
|
40
|
+
macOS exposes /var as a symlink to /private/var. A fixture rooted in mktemp
|
|
41
|
+
must be valid when the root itself is a real directory, while an evidence
|
|
42
|
+
path must never traverse a symlink at or below that root.
|
|
43
|
+
"""
|
|
44
|
+
root = root.absolute()
|
|
45
|
+
if root.is_symlink():
|
|
46
|
+
raise ValueError(f"symlink root is not allowed: {label}")
|
|
47
|
+
current = root
|
|
48
|
+
for part in relative.parts:
|
|
42
49
|
current = current / part
|
|
43
50
|
if current.is_symlink():
|
|
44
|
-
raise ValueError(f"symlink path component is not allowed: {
|
|
51
|
+
raise ValueError(f"symlink path component is not allowed: {label}")
|
|
45
52
|
|
|
46
53
|
|
|
47
54
|
def safe_relative_file(root: Path, relative: object, label: str) -> Path:
|
|
@@ -53,7 +60,7 @@ def safe_relative_file(root: Path, relative: object, label: str) -> Path:
|
|
|
53
60
|
if any(part in ("", ".", "..") for part in candidate.parts):
|
|
54
61
|
raise ValueError(f"{label} contains unsafe path components")
|
|
55
62
|
joined = root / candidate
|
|
56
|
-
|
|
63
|
+
reject_symlink_within_root(root, candidate, label)
|
|
57
64
|
resolved_root = root.resolve()
|
|
58
65
|
resolved = joined.resolve(strict=False)
|
|
59
66
|
if resolved != resolved_root and resolved_root not in resolved.parents:
|
|
@@ -81,8 +88,10 @@ def verify(scene_dir: Path, task_dir: Path, runtime_evidence_name: str, max_age_
|
|
|
81
88
|
raise ValueError("scene directory name is unsafe")
|
|
82
89
|
if not SAFE_NAME.fullmatch(task_dir.name):
|
|
83
90
|
raise ValueError("task directory name is unsafe")
|
|
84
|
-
|
|
85
|
-
|
|
91
|
+
if scene_dir.is_symlink():
|
|
92
|
+
raise ValueError("scene directory symlink is not allowed")
|
|
93
|
+
if task_dir.is_symlink():
|
|
94
|
+
raise ValueError("task directory symlink is not allowed")
|
|
86
95
|
manifest_path = safe_relative_file(scene_dir, "manifest.json", "manifest.json")
|
|
87
96
|
source_manifest = parse_json(manifest_path)
|
|
88
97
|
if not isinstance(source_manifest, dict):
|
package/scripts/intelligence.py
CHANGED
|
@@ -82,14 +82,22 @@ def safe_relative(value: str) -> Path:
|
|
|
82
82
|
return path
|
|
83
83
|
|
|
84
84
|
|
|
85
|
-
def has_symlink_component(path: Path) -> bool:
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
def has_symlink_component(path: Path, root: Path) -> bool:
|
|
86
|
+
"""Reject symlinks at or below a trusted root, not system parent aliases."""
|
|
87
|
+
root = root.absolute()
|
|
88
|
+
path = path.absolute()
|
|
89
|
+
try:
|
|
90
|
+
relative = path.relative_to(root)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return True
|
|
93
|
+
if root.is_symlink():
|
|
94
|
+
return True
|
|
95
|
+
current = root
|
|
96
|
+
for component in relative.parts:
|
|
97
|
+
current = current / component
|
|
88
98
|
if current.is_symlink():
|
|
89
99
|
return True
|
|
90
|
-
|
|
91
|
-
return False
|
|
92
|
-
current = current.parent
|
|
100
|
+
return False
|
|
93
101
|
|
|
94
102
|
|
|
95
103
|
def within(root: Path, candidate: Path) -> bool:
|
|
@@ -104,7 +112,7 @@ def task_artifact(task_dir: Path, relative: str) -> dict[str, Any]:
|
|
|
104
112
|
safe = safe_relative(relative)
|
|
105
113
|
raw_path = task_dir / safe
|
|
106
114
|
path = raw_path.resolve()
|
|
107
|
-
if has_symlink_component(raw_path) or not within(task_dir, path) or not path.is_file():
|
|
115
|
+
if has_symlink_component(raw_path, task_dir) or not within(task_dir, path) or not path.is_file():
|
|
108
116
|
raise ValueError(f"artifact is missing or outside task bundle: {relative}")
|
|
109
117
|
return {
|
|
110
118
|
"id": relative.replace("/", ":"),
|
|
@@ -179,7 +187,7 @@ def graph_build(args: argparse.Namespace) -> int:
|
|
|
179
187
|
|
|
180
188
|
task_files = []
|
|
181
189
|
for path in sorted(task_dir.rglob("*")):
|
|
182
|
-
if has_symlink_component(path):
|
|
190
|
+
if has_symlink_component(path, task_dir):
|
|
183
191
|
raise ValueError(f"task bundle cannot contain symlinked artifact: {path.relative_to(task_dir)}")
|
|
184
192
|
if not path.is_file():
|
|
185
193
|
continue
|
|
@@ -363,7 +371,7 @@ def provenance_validate(args: argparse.Namespace) -> int:
|
|
|
363
371
|
if not isinstance(attestation, dict) or not isinstance(attestation.get("steps"), list):
|
|
364
372
|
raise ValueError("provenance attestation must contain steps")
|
|
365
373
|
task_dir = task_dir_from(args.task_dir)
|
|
366
|
-
if has_symlink_component(
|
|
374
|
+
if has_symlink_component(path, task_dir) or not within(task_dir, path):
|
|
367
375
|
raise ValueError("provenance attestation must be a non-symlink file inside task bundle")
|
|
368
376
|
if attestation.get("task_id") != read_json(task_dir / "task.json").get("task_id"):
|
|
369
377
|
raise ValueError("provenance task_id does not match task.json")
|
|
@@ -400,13 +408,13 @@ def capability_build(args: argparse.Namespace) -> int:
|
|
|
400
408
|
if not isinstance(card, dict):
|
|
401
409
|
raise ValueError("agent-card must be an object")
|
|
402
410
|
raw_evidence_path = Path(args.evidence).expanduser() if args.evidence else ROOT / "scripts" / "runtime-adapters.mjs"
|
|
403
|
-
if has_symlink_component(raw_evidence_path):
|
|
404
|
-
raise ValueError("capability evidence cannot traverse symlinks")
|
|
405
411
|
evidence_path = raw_evidence_path.resolve()
|
|
406
412
|
if not evidence_path.is_file():
|
|
407
413
|
raise ValueError(f"capability evidence path does not exist: {evidence_path}")
|
|
408
414
|
if not within(ROOT, evidence_path):
|
|
409
415
|
raise ValueError("capability evidence must be inside the repository")
|
|
416
|
+
if has_symlink_component(raw_evidence_path, ROOT):
|
|
417
|
+
raise ValueError("capability evidence cannot traverse symlinks")
|
|
410
418
|
evidence_ref = evidence_path.relative_to(ROOT).as_posix()
|
|
411
419
|
evidence_kind = args.evidence_kind
|
|
412
420
|
entries = []
|
|
@@ -445,6 +453,13 @@ def capability_build(args: argparse.Namespace) -> int:
|
|
|
445
453
|
def capability_validate(args: argparse.Namespace) -> int:
|
|
446
454
|
registry_path = Path(args.path).expanduser().resolve()
|
|
447
455
|
registry = read_json(registry_path)
|
|
456
|
+
capability_registry_validate(registry)
|
|
457
|
+
ids = [entry.get("id") for entry in registry["capabilities"]]
|
|
458
|
+
print(json.dumps({"status": "valid", "kind": "capability-registry", "capability_count": len(ids)}, ensure_ascii=False))
|
|
459
|
+
return 0
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def capability_registry_validate(registry: Any) -> None:
|
|
448
463
|
if not isinstance(registry, dict) or not isinstance(registry.get("capabilities"), list):
|
|
449
464
|
raise ValueError("capability registry must contain capabilities")
|
|
450
465
|
ids = [entry.get("id") for entry in registry["capabilities"]]
|
|
@@ -453,13 +468,61 @@ def capability_validate(args: argparse.Namespace) -> int:
|
|
|
453
468
|
for entry in registry["capabilities"]:
|
|
454
469
|
for evidence in entry.get("evidence", []):
|
|
455
470
|
evidence_path = ROOT / safe_relative(str(evidence.get("path", "")))
|
|
456
|
-
if has_symlink_component(evidence_path) or not within(ROOT, evidence_path):
|
|
471
|
+
if has_symlink_component(evidence_path, ROOT) or not within(ROOT, evidence_path):
|
|
457
472
|
raise ValueError(f"capability evidence escapes repository: {evidence.get('path')}")
|
|
458
473
|
if not evidence_path.is_file():
|
|
459
474
|
raise ValueError(f"capability evidence missing: {evidence.get('path')}")
|
|
460
475
|
if digest_file(evidence_path) != evidence.get("sha256"):
|
|
461
476
|
raise ValueError(f"capability evidence hash mismatch: {evidence.get('path')}")
|
|
462
|
-
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def capability_card(args: argparse.Namespace) -> int:
|
|
480
|
+
"""Export a read-only, evidence-bound capability projection for Agents.
|
|
481
|
+
|
|
482
|
+
The card deliberately exposes declared registry state only. It does not
|
|
483
|
+
select a runtime, refresh evidence, grant approval, or change policy.
|
|
484
|
+
Callers must run ``capabilities select`` before execution.
|
|
485
|
+
"""
|
|
486
|
+
registry = read_json(Path(args.registry).expanduser().resolve())
|
|
487
|
+
capability_registry_validate(registry)
|
|
488
|
+
capabilities = []
|
|
489
|
+
for entry in registry["capabilities"]:
|
|
490
|
+
capabilities.append({
|
|
491
|
+
"id": entry.get("id"),
|
|
492
|
+
"kind": entry.get("kind"),
|
|
493
|
+
"declared_status": entry.get("status"),
|
|
494
|
+
"adapter_version": entry.get("adapter_version"),
|
|
495
|
+
"last_verified_at": entry.get("last_verified_at"),
|
|
496
|
+
"inputs": entry.get("inputs", []),
|
|
497
|
+
"outputs": entry.get("outputs", []),
|
|
498
|
+
"compatibility": entry.get("compatibility", {}),
|
|
499
|
+
"evidence": entry.get("evidence", []),
|
|
500
|
+
"limitations": entry.get("limitations", []),
|
|
501
|
+
"fallback": entry.get("fallback", ""),
|
|
502
|
+
"risk_level": entry.get("risk_level"),
|
|
503
|
+
"side_effect_level": entry.get("side_effect_level"),
|
|
504
|
+
})
|
|
505
|
+
card = {
|
|
506
|
+
"schema_version": "0.1",
|
|
507
|
+
"kind": "motionloom-capability-card",
|
|
508
|
+
"registry": {
|
|
509
|
+
"registry_id": registry.get("registry_id"),
|
|
510
|
+
"schema_version": registry.get("schema_version"),
|
|
511
|
+
"generated_at": registry.get("generated_at"),
|
|
512
|
+
"selection_policy": registry.get("selection_policy", {}),
|
|
513
|
+
},
|
|
514
|
+
"capabilities": capabilities,
|
|
515
|
+
"selection": {
|
|
516
|
+
"required": True,
|
|
517
|
+
"command": "motionloom capability select --registry <capability-registry.json> --capability <runtime.id>",
|
|
518
|
+
"note": "This card is discovery metadata. Select validates evidence freshness and integrity before a runtime may be used.",
|
|
519
|
+
},
|
|
520
|
+
"review": {
|
|
521
|
+
"production_approval": "not_derived",
|
|
522
|
+
"attestation_approval": "not_derived",
|
|
523
|
+
},
|
|
524
|
+
}
|
|
525
|
+
print(json.dumps(card, ensure_ascii=False))
|
|
463
526
|
return 0
|
|
464
527
|
|
|
465
528
|
|
|
@@ -488,7 +551,7 @@ def capability_select(args: argparse.Namespace) -> int:
|
|
|
488
551
|
for evidence in entry.get("evidence", []):
|
|
489
552
|
try:
|
|
490
553
|
evidence_path = ROOT / safe_relative(str(evidence.get("path", "")))
|
|
491
|
-
if has_symlink_component(evidence_path) or not within(ROOT, evidence_path) or not evidence_path.is_file() or digest_file(evidence_path) != evidence.get("sha256"):
|
|
554
|
+
if has_symlink_component(evidence_path, ROOT) or not within(ROOT, evidence_path) or not evidence_path.is_file() or digest_file(evidence_path) != evidence.get("sha256"):
|
|
492
555
|
evidence_ok = False
|
|
493
556
|
break
|
|
494
557
|
except (OSError, ValueError):
|
|
@@ -1383,7 +1446,7 @@ def replay_capture(args: argparse.Namespace) -> int:
|
|
|
1383
1446
|
task_rel = task_dir.relative_to(root).as_posix()
|
|
1384
1447
|
records = []
|
|
1385
1448
|
for path in sorted(task_dir.rglob("*")):
|
|
1386
|
-
if has_symlink_component(path):
|
|
1449
|
+
if has_symlink_component(path, task_dir):
|
|
1387
1450
|
raise ValueError(f"task bundle cannot contain symlinked artifact: {path.relative_to(task_dir)}")
|
|
1388
1451
|
if not path.is_file():
|
|
1389
1452
|
continue
|
|
@@ -1417,7 +1480,7 @@ def replay_verify(args: argparse.Namespace) -> int:
|
|
|
1417
1480
|
task_rel = safe_relative(str(bundle.get("task_dir", "")))
|
|
1418
1481
|
task_dir = (root / task_rel).resolve()
|
|
1419
1482
|
task = read_json(task_dir / "task.json")
|
|
1420
|
-
if has_symlink_component(root / task_rel) or not within(root, task_dir) or not task_dir.is_dir():
|
|
1483
|
+
if has_symlink_component(root / task_rel, root) or not within(root, task_dir) or not task_dir.is_dir():
|
|
1421
1484
|
mismatches.append({"path": str(task_rel), "reason": "task_dir_invalid"})
|
|
1422
1485
|
else:
|
|
1423
1486
|
if bundle.get("task_id") != task.get("task_id"):
|
|
@@ -1430,7 +1493,7 @@ def replay_verify(args: argparse.Namespace) -> int:
|
|
|
1430
1493
|
safe = safe_relative(str(record.get("path", "")))
|
|
1431
1494
|
raw_path = root / safe
|
|
1432
1495
|
path = raw_path.resolve()
|
|
1433
|
-
if has_symlink_component(raw_path) or not within(root, path) or "task_dir" in locals() and not within(task_dir, path) or not path.is_file():
|
|
1496
|
+
if has_symlink_component(raw_path, root) or not within(root, path) or "task_dir" in locals() and not within(task_dir, path) or not path.is_file():
|
|
1434
1497
|
mismatches.append({"path": str(safe), "reason": "missing"})
|
|
1435
1498
|
continue
|
|
1436
1499
|
actual = digest_file(path)
|
|
@@ -1449,7 +1512,7 @@ def replay_mismatches(bundle: dict[str, Any], root: Path) -> list[dict[str, Any]
|
|
|
1449
1512
|
safe = safe_relative(str(record.get("path", "")))
|
|
1450
1513
|
raw_path = root / safe
|
|
1451
1514
|
path = raw_path.resolve()
|
|
1452
|
-
if has_symlink_component(raw_path) or not within(root, path) or not path.is_file():
|
|
1515
|
+
if has_symlink_component(raw_path, root) or not within(root, path) or not path.is_file():
|
|
1453
1516
|
mismatches.append({"path": str(safe), "reason": "missing"})
|
|
1454
1517
|
continue
|
|
1455
1518
|
actual = digest_file(path)
|
|
@@ -1563,6 +1626,10 @@ def add_subcommands(parser: argparse.ArgumentParser) -> None:
|
|
|
1563
1626
|
capabilities_select_parser.add_argument("--status", default="verified")
|
|
1564
1627
|
capabilities_select_parser.add_argument("--allow-scaffold-only", action="store_true")
|
|
1565
1628
|
capabilities_select_parser.set_defaults(func=capability_select)
|
|
1629
|
+
capabilities_card_parser = capabilities_sub.add_parser("card", help="export a read-only capability card for Agent discovery")
|
|
1630
|
+
capabilities_card_parser.add_argument("--registry", default=str(ROOT / "capability-registry.json"))
|
|
1631
|
+
capabilities_card_parser.add_argument("--format", choices=["json"], default="json")
|
|
1632
|
+
capabilities_card_parser.set_defaults(func=capability_card)
|
|
1566
1633
|
|
|
1567
1634
|
replay = sub.add_parser("replay", help="capture or verify clean-root integrity replay bundle")
|
|
1568
1635
|
replay_sub = replay.add_subparsers(dest="action", required=True)
|
|
@@ -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(
|
|
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
|
-
|
|
21
|
-
|
|
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}`;
|
package/scripts/skill-doctor.py
CHANGED
|
@@ -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 = [
|
|
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),
|