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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "motionloom",
3
- "version": "2.4.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.",
@@ -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": "node bin/motionloom.mjs 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": "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",
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
- "devDependencies": {
199
+ "optionalDependencies": {
193
200
  "playwright": "^1.62.1",
194
201
  "vite": "^8.2.1"
195
202
  },
203
+ "devDependencies": {},
196
204
  "license": "MIT"
197
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
  ]
@@ -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
- 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 [
@@ -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 reject_symlink_components(path: Path) -> None:
38
- current = Path(path.anchor) if path.anchor else Path(".")
39
- for part in path.parts:
40
- if part in (path.anchor, ""):
41
- continue
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: {path}")
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
- reject_symlink_components(joined)
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
- reject_symlink_components(scene_dir)
85
- reject_symlink_components(task_dir)
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):
@@ -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
- current = path
87
- while True:
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
- if current.parent == current:
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(raw_path) or not within(task_dir, path):
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
- print(json.dumps({"status": "valid", "kind": "capability-registry", "capability_count": len(ids)}, ensure_ascii=False))
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(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),