motionloom 2.6.1 → 2.7.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 (69) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +42 -0
  3. package/CONTRIBUTING.md +3 -1
  4. package/README.md +19 -3
  5. package/SECURITY.md +5 -5
  6. package/SKILL.md +6 -3
  7. package/agent-card.json +18 -4
  8. package/agent-surfaces.json +1 -1
  9. package/artifact-adapter-registry.json +568 -20
  10. package/bin/motionloom.mjs +21 -4
  11. package/capability-registry.json +58 -234
  12. package/dev-lab/public/devlab.js +36 -3
  13. package/dev-lab/public/index.html +6 -2
  14. package/docs/ACTION-SEPARATION.md +104 -0
  15. package/docs/ASSET-GENERATION-PLANNER.md +101 -0
  16. package/docs/BRANCH-PROTECTION.md +44 -0
  17. package/docs/EXTERNAL-CORPUS.md +26 -0
  18. package/docs/STATUS.md +3 -2
  19. package/docs/audits/field-test-after-hardening-2026-08-21.md +25 -0
  20. package/docs/releases/2.7.0.md +98 -0
  21. package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.00.json +32 -0
  22. package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.01.json +32 -0
  23. package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.02.json +32 -0
  24. package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.03.json +32 -0
  25. package/examples/agent-consumer/asset-consistency/action-sequence/hero-walk-action-manifest.json +81 -0
  26. package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.00.json +26 -0
  27. package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.01.json +26 -0
  28. package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.02.json +26 -0
  29. package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.03.json +26 -0
  30. package/examples/agent-consumer/asset-planning/pixellab-hero-256x448-request.json +43 -0
  31. package/examples/agent-consumer/devlab-live-sprite/README.md +30 -0
  32. package/examples/agent-consumer/devlab-live-sprite/devlab-runtime.json +61 -0
  33. package/examples/agent-consumer/devlab-live-sprite/frames/idle-00.png +0 -0
  34. package/examples/agent-consumer/devlab-live-sprite/frames/idle-01.png +0 -0
  35. package/examples/agent-consumer/devlab-live-sprite/frames/idle-02.png +0 -0
  36. package/examples/agent-consumer/devlab-live-sprite/frames/reverse-00.png +0 -0
  37. package/examples/agent-consumer/devlab-live-sprite/frames/reverse-01.png +0 -0
  38. package/examples/agent-consumer/devlab-live-sprite/frames/reverse-02.png +0 -0
  39. package/examples/agent-consumer/frame-generation-lock/hero-walk-lock.json +14 -1
  40. package/package.json +15 -1
  41. package/references/multi-frame-asset-generation.md +37 -2
  42. package/schemas/action-separation-verifier-evidence.schema.json +43 -0
  43. package/schemas/action-sequence-manifest.schema.json +48 -0
  44. package/schemas/artifact-adapter-registry.schema.json +1 -1
  45. package/schemas/asset-adaptation.schema.json +21 -0
  46. package/schemas/asset-generation-plan.schema.json +77 -0
  47. package/schemas/asset-generation-request.schema.json +104 -0
  48. package/schemas/frame-envelope.schema.json +62 -0
  49. package/schemas/frame-generation-lock.schema.json +24 -1
  50. package/scripts/action-separation.py +410 -0
  51. package/scripts/asset-adapt.mjs +92 -0
  52. package/scripts/asset-generation-plan.py +613 -0
  53. package/scripts/browser_review_consistency.py +64 -0
  54. package/scripts/fetch-project-corpus.py +91 -0
  55. package/scripts/frame-generation-lock.py +35 -5
  56. package/scripts/frame-set-preflight.py +52 -2
  57. package/scripts/package-consumer-smoke.mjs +20 -0
  58. package/scripts/quality-gate.py +7 -0
  59. package/scripts/release-verify.py +25 -0
  60. package/scripts/report-contract.py +1 -1
  61. package/scripts/report.py +19 -4
  62. package/scripts/resolve-task-bundle.py +11 -3
  63. package/scripts/review-hook.py +51 -2
  64. package/scripts/skill-doctor.py +42 -0
  65. package/src/output/browser-review-smoke/browser-review.json +6 -6
  66. package/tests/scripts/run_tests.py +45 -2
  67. package/tests/scripts/test_asset_adapt.py +47 -0
  68. package/tests/scripts/test_asset_generation_plan.py +250 -0
  69. package/tests/scripts/test_attestation.py +24 -8
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  import hashlib
8
+ import os
8
9
  import json
9
10
  import re
10
11
  import shutil
@@ -14,6 +15,12 @@ from datetime import datetime, timedelta, timezone
14
15
  from pathlib import Path
15
16
  from urllib.parse import urlencode, urlsplit, urlunsplit
16
17
 
18
+ try:
19
+ from browser_review_consistency import candidate_consistency_errors
20
+ except ModuleNotFoundError: # Support importlib-based contract tests.
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
+ from browser_review_consistency import candidate_consistency_errors
23
+
17
24
  ROOT = Path(__file__).resolve().parents[1]
18
25
  SAFE_SCENE = re.compile(r"^[A-Za-z0-9._-]+$")
19
26
  SAFE_ANIMATION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
@@ -182,6 +189,20 @@ def runtime_bundle(scene_dir: Path) -> dict | None:
182
189
  review_policy = descriptor.get("review_policy")
183
190
  if not isinstance(review_policy, dict) or not isinstance(review_policy.get("require_all_animations"), bool):
184
191
  raise ValueError("devlab-runtime.json review_policy.require_all_animations must be boolean")
192
+ action_separation = descriptor.get("action_separation")
193
+ if action_separation is not None:
194
+ if not isinstance(action_separation, dict):
195
+ raise ValueError("devlab-runtime.json action_separation must be an object")
196
+ if action_separation.get("status") not in {"pass", "quarantined"}:
197
+ raise ValueError("devlab-runtime.json action_separation.status must be pass or quarantined")
198
+ if not isinstance(action_separation.get("action_id"), str) or not SAFE_ANIMATION.fullmatch(action_separation["action_id"]):
199
+ raise ValueError("devlab-runtime.json action_separation.action_id is invalid")
200
+ frame_count = action_separation.get("frame_count")
201
+ passing_count = action_separation.get("passing_frame_count")
202
+ if not isinstance(frame_count, int) or frame_count < 1 or not isinstance(passing_count, int) or passing_count < 0 or passing_count > frame_count:
203
+ raise ValueError("devlab-runtime.json action_separation frame counts are invalid")
204
+ if not isinstance(action_separation.get("forbidden_action_ids"), list):
205
+ raise ValueError("devlab-runtime.json action_separation.forbidden_action_ids must be an array")
185
206
 
186
207
  digest = hashlib.sha256()
187
208
  digest.update(b"motionloom-devlab-runtime-v1\0")
@@ -198,6 +219,7 @@ def runtime_bundle(scene_dir: Path) -> dict | None:
198
219
  "mode": mode,
199
220
  "files": sorted(resolved_files),
200
221
  "review_policy": {"require_all_animations": review_policy["require_all_animations"]},
222
+ "action_separation": action_separation,
201
223
  }
202
224
 
203
225
 
@@ -215,6 +237,7 @@ def runtime_review_payload(bundle: dict | None) -> dict:
215
237
  "bundle_sha256": bundle["bundle_sha256"],
216
238
  "animations": bundle["animations"],
217
239
  "review_policy": bundle["review_policy"],
240
+ "action_separation": bundle.get("action_separation"),
218
241
  }
219
242
 
220
243
 
@@ -334,8 +357,24 @@ def prepare(args: argparse.Namespace) -> int:
334
357
  )),
335
358
  })
336
359
  handoff_path.write_text(json.dumps(handoff, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
337
- subprocess.run([sys.executable, str(ROOT / "scripts/devlab.py"), scene, "--prepare-only", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
338
- subprocess.run([sys.executable, str(ROOT / "scripts/report.py"), "collect", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
360
+ project_env = os.environ.copy()
361
+ project_env["MOTIONLOOM_PROJECT_ROOT"] = str(ROOT)
362
+ subprocess.run(
363
+ [sys.executable, str(ROOT / "scripts/devlab.py"), scene, "--prepare-only", "--task-dir", str(task_dir)],
364
+ check=True,
365
+ capture_output=True,
366
+ text=True,
367
+ cwd=ROOT,
368
+ env=project_env,
369
+ )
370
+ subprocess.run(
371
+ [sys.executable, str(ROOT / "scripts/report.py"), "collect", "--task-dir", str(task_dir)],
372
+ check=True,
373
+ capture_output=True,
374
+ text=True,
375
+ cwd=ROOT,
376
+ env=project_env,
377
+ )
339
378
  print(json.dumps({
340
379
  "status": "review_required",
341
380
  "task_id": task["task_id"],
@@ -363,6 +402,12 @@ def validate(args: argparse.Namespace) -> int:
363
402
  bundle["bundle_sha256"] if bundle else None,
364
403
  )
365
404
  errors = []
405
+ errors.extend(candidate_consistency_errors(
406
+ scene_dir / "browser-review.json",
407
+ task_dir / "browser-review.json",
408
+ expected_task_id=task.get("task_id"),
409
+ expected_scene=task.get("scene"),
410
+ ))
366
411
  if not candidate.get("expires_at"):
367
412
  errors.append("browser-review candidate has no expiry")
368
413
  else:
@@ -446,17 +491,21 @@ def validate(args: argparse.Namespace) -> int:
446
491
 
447
492
 
448
493
  def main() -> int:
494
+ global ROOT
449
495
  parser = argparse.ArgumentParser(description=__doc__)
450
496
  sub = parser.add_subparsers(dest="command", required=True)
451
497
  p = sub.add_parser("prepare")
452
498
  p.add_argument("--task-dir", required=True)
499
+ p.add_argument("--root", default=str(ROOT), help="Repository root containing the canonical scene artifacts")
453
500
  p.add_argument("--lab-url", default="http://127.0.0.1:3300")
454
501
  p.set_defaults(func=prepare)
455
502
  v = sub.add_parser("validate")
456
503
  v.add_argument("--task-dir", required=True)
504
+ v.add_argument("--root", default=str(ROOT), help="Repository root containing the canonical scene artifacts")
457
505
  v.add_argument("--require-approved", action="store_true")
458
506
  v.set_defaults(func=validate)
459
507
  args = parser.parse_args()
508
+ ROOT = Path(args.root).expanduser().resolve()
460
509
  try:
461
510
  return args.func(args)
462
511
  except (KeyError, FileNotFoundError, json.JSONDecodeError, ValueError, subprocess.CalledProcessError) as exc:
@@ -7,6 +7,7 @@ import argparse
7
7
  import importlib.util
8
8
  import json
9
9
  import re
10
+ import subprocess
10
11
  import sys
11
12
  from pathlib import Path
12
13
 
@@ -71,9 +72,35 @@ def parse_frontmatter(text: str) -> dict[str, str] | None:
71
72
  return values
72
73
 
73
74
 
75
+ def chromium_executable() -> tuple[Path | None, str | None]:
76
+ probe = (
77
+ "import { chromium } from 'playwright'; "
78
+ "process.stdout.write(chromium.executablePath());"
79
+ )
80
+ try:
81
+ result = subprocess.run(
82
+ ["node", "--input-type=module", "--eval", probe],
83
+ cwd=ROOT,
84
+ capture_output=True,
85
+ text=True,
86
+ timeout=10,
87
+ )
88
+ except (OSError, subprocess.SubprocessError) as exc:
89
+ return None, str(exc)
90
+ if result.returncode != 0:
91
+ return None, (result.stderr or result.stdout).strip() or "Playwright import failed"
92
+ path = Path(result.stdout.strip())
93
+ return (path if path else None), None
94
+
95
+
74
96
  def run() -> int:
75
97
  parser = argparse.ArgumentParser(description=__doc__)
76
98
  parser.add_argument("--json", action="store_true", dest="as_json")
99
+ parser.add_argument(
100
+ "--runtime",
101
+ action="store_true",
102
+ help="also verify that the Playwright Chromium executable is installed",
103
+ )
77
104
  args = parser.parse_args()
78
105
  errors: list[dict] = []
79
106
  warnings: list[dict] = []
@@ -156,6 +183,21 @@ def run() -> int:
156
183
  except (FileNotFoundError, json.JSONDecodeError) as exc:
157
184
  errors.append({"code": "invalid_package_json", "message": str(exc)})
158
185
 
186
+ if args.runtime:
187
+ chromium_path, chromium_error = chromium_executable()
188
+ chromium_ready = chromium_path is not None and chromium_path.is_file()
189
+ checks.append({
190
+ "id": "runtime:chromium",
191
+ "status": "pass" if chromium_ready else "fail",
192
+ "path": str(chromium_path) if chromium_path else None,
193
+ })
194
+ if not chromium_ready:
195
+ detail = chromium_error or f"Chromium executable is missing: {chromium_path}"
196
+ errors.append({
197
+ "code": "missing_browser_executable",
198
+ "message": f"Playwright Chromium is unavailable ({detail}); run `npx playwright install chromium`.",
199
+ })
200
+
159
201
  cryptography_available = importlib.util.find_spec("cryptography") is not None
160
202
  checks.append({"id": "python-dependency:cryptography", "status": "pass" if cryptography_available else "fail"})
161
203
  if not cryptography_available:
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schema_version": "1.0",
3
- "candidate_id": "88a2f2f18ba45a07f56e",
4
- "task_id": "professional-review-e2e",
3
+ "candidate_id": "956caeb2f56397430c4f",
4
+ "task_id": "browser-review-smoke-task",
5
5
  "scene": "browser-review-smoke",
6
- "url": "https://3300-i8isn2ocahcvfq0jk51ta-68c15abe.sg1.manus.computer/?scene=browser-review-smoke&task_id=professional-review-e2e&candidate_id=88a2f2f18ba45a07f56e&artifact_base=https%3A%2F%2F3300-i8isn2ocahcvfq0jk51ta-68c15abe.sg1.manus.computer%2Fscenes%2Fbrowser-review-smoke&task_base=https%3A%2F%2F3300-i8isn2ocahcvfq0jk51ta-68c15abe.sg1.manus.computer%2Ftasks%2Fprofessional-review-e2e",
6
+ "url": "http://127.0.0.1:3300/?scene=browser-review-smoke&task_id=browser-review-smoke-task&candidate_id=956caeb2f56397430c4f",
7
7
  "status": "approved",
8
8
  "context_sha256": "cbbd43a53a0ddd5cf5452df5f6d521f06829465bd58c6d764a59d501ed1710fa",
9
9
  "source_sha256": "c5ab312427678b17ae903d280d89db9d202670f031f10ad6fe774fc9aaecee8a",
@@ -15,7 +15,7 @@
15
15
  ],
16
16
  "review_artifact": "review.json",
17
17
  "requires_user_approval": true,
18
- "prepared_at": "2026-08-12T17:42:31Z",
19
- "expires_at": "2026-08-13T17:42:31Z",
20
- "reviewed_at": "2026-08-12T17:48:54Z"
18
+ "prepared_at": "2026-08-12T14:29:17Z",
19
+ "expires_at": "2099-01-01T00:00:00Z",
20
+ "reviewed_at": "2026-08-12T14:32:51Z"
21
21
  }
@@ -511,6 +511,9 @@ def test_approved_browser_review_e2e_contract():
511
511
  candidate = json.loads(candidate_path.read_text())
512
512
  candidate["expires_at"] = "2099-01-01T00:00:00Z"
513
513
  candidate_path.write_text(json.dumps(candidate, indent=2) + "\n")
514
+ scene_candidate_path = root / "src/output/browser-review-smoke/browser-review.json"
515
+ scene_candidate = dict(candidate)
516
+ scene_candidate_path.write_text(json.dumps(scene_candidate, indent=2) + "\n")
514
517
 
515
518
  review = json.loads((task_dir / "review.json").read_text())
516
519
  task = json.loads((task_dir / "task.json").read_text())
@@ -524,7 +527,7 @@ def test_approved_browser_review_e2e_contract():
524
527
 
525
528
  review_hook = subprocess.run([
526
529
  sys.executable, str(ROOT / "scripts/review-hook.py"), "validate",
527
- "--task-dir", str(task_dir), "--require-approved",
530
+ "--task-dir", str(task_dir), "--root", str(root), "--require-approved",
528
531
  ], capture_output=True, text=True)
529
532
  check("e2e review hook accepts approved candidate", review_hook.returncode == 0, review_hook.stdout.strip())
530
533
 
@@ -536,7 +539,7 @@ def test_approved_browser_review_e2e_contract():
536
539
  check("e2e quality gate accepts task evidence", quality.returncode == 0, quality.stdout.strip())
537
540
 
538
541
  report_check = subprocess.run([
539
- sys.executable, str(ROOT / "scripts/report.py"), "check", "--task-dir", str(task_dir),
542
+ sys.executable, str(ROOT / "scripts/report.py"), "check", "--task-dir", str(task_dir), "--root", str(root),
540
543
  ], capture_output=True, text=True)
541
544
  check("e2e report contract accepts confirmed task", report_check.returncode == 0, report_check.stdout.strip())
542
545
 
@@ -1162,6 +1165,46 @@ if __name__ == "__main__":
1162
1165
  test_category_coverage()
1163
1166
  test_observability_contract()
1164
1167
  test_quality_workflow_rebuilds_replay_after_generated_artifacts()
1168
+ browser_review_consistency_tests = subprocess.run(
1169
+ [sys.executable, str(ROOT / "tests/scripts/test_browser_review_consistency.py")],
1170
+ capture_output=True,
1171
+ text=True,
1172
+ )
1173
+ check(
1174
+ "browser review scene/task candidate consistency is fail-closed",
1175
+ browser_review_consistency_tests.returncode == 0 and "browser review consistency tests: PASS" in browser_review_consistency_tests.stdout,
1176
+ browser_review_consistency_tests.stdout.strip() or browser_review_consistency_tests.stderr.strip(),
1177
+ )
1178
+ action_separation_tests = subprocess.run(
1179
+ [sys.executable, str(ROOT / "tests/scripts/test_action_separation.py")],
1180
+ capture_output=True,
1181
+ text=True,
1182
+ )
1183
+ check(
1184
+ "action-scoped frame manifests reject cross-action and ambiguous frames",
1185
+ action_separation_tests.returncode == 0 and "action separation tests: PASS" in action_separation_tests.stdout,
1186
+ action_separation_tests.stdout.strip() or action_separation_tests.stderr.strip(),
1187
+ )
1188
+ asset_generation_plan_tests = subprocess.run(
1189
+ [sys.executable, str(ROOT / "tests/scripts/test_asset_generation_plan.py")],
1190
+ capture_output=True,
1191
+ text=True,
1192
+ )
1193
+ check(
1194
+ "asset planner recommends explicit provider and canvas adaptations",
1195
+ asset_generation_plan_tests.returncode == 0 and "asset generation planner contract tests: PASS" in asset_generation_plan_tests.stdout,
1196
+ asset_generation_plan_tests.stdout.strip() or asset_generation_plan_tests.stderr.strip(),
1197
+ )
1198
+ asset_adapt_tests = subprocess.run(
1199
+ [sys.executable, str(ROOT / "tests/scripts/test_asset_adapt.py")],
1200
+ capture_output=True,
1201
+ text=True,
1202
+ )
1203
+ check(
1204
+ "asset adaptation preserves target geometry without crop or stretch",
1205
+ asset_adapt_tests.returncode == 0 and "asset adaptation contract tests: PASS" in asset_adapt_tests.stdout,
1206
+ asset_adapt_tests.stdout.strip() or asset_adapt_tests.stderr.strip(),
1207
+ )
1165
1208
  print()
1166
1209
  if FAILED:
1167
1210
  print(f"{len(FAILED)} test(s) FAILED: {', '.join(FAILED)}")
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ ROOT = Path(__file__).resolve().parents[2]
10
+ SCRIPT = ROOT / "scripts" / "asset-adapt.mjs"
11
+ SOURCE = ROOT / "examples/agent-consumer/asset-consistency/assets/hero-frame-00.png"
12
+
13
+
14
+ def check(condition: bool, message: str) -> None:
15
+ if not condition:
16
+ raise AssertionError(message)
17
+
18
+
19
+ def main() -> int:
20
+ with tempfile.TemporaryDirectory() as td:
21
+ root = Path(td)
22
+ output = root / "padded.png"
23
+ report = root / "adaptation.json"
24
+ result = subprocess.run([
25
+ "node", str(SCRIPT), "pad", "--input", str(SOURCE), "--output", str(output),
26
+ "--width", "16", "--height", "16", "--scale", "1", "--anchor", "center",
27
+ "--report", str(report), "--json",
28
+ ], capture_output=True, text=True)
29
+ check(result.returncode == 0, result.stderr)
30
+ doc = json.loads(report.read_text())
31
+ check(doc["approval"] is False and doc["production_approved"] is False, "adaptation cannot grant approval")
32
+ check(doc["source"]["canvas"] == [8, 8] and doc["output"]["canvas"] == [16, 16], "adaptation must bind source/output geometry")
33
+ check(doc["transform"]["crop"] is False and doc["transform"]["stretch"] is False, "crop/stretch must remain false")
34
+ check(doc["transform"]["interpolation"] == "nearest-neighbour", "pixel art must use nearest-neighbour")
35
+ check(output.is_file() and output.stat().st_size > 0, "adapter must emit target PNG")
36
+
37
+ blocked = subprocess.run([
38
+ "node", str(SCRIPT), "pad", "--input", str(SOURCE), "--output", str(root / "blocked.png"),
39
+ "--width", "4", "--height", "4", "--json",
40
+ ], capture_output=True, text=True)
41
+ check(blocked.returncode != 0 and "crop is forbidden" in blocked.stderr, "oversized source must fail closed")
42
+ print("asset adaptation contract tests: PASS")
43
+ return 0
44
+
45
+
46
+ if __name__ == "__main__":
47
+ raise SystemExit(main())
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import importlib.util
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ ROOT = Path(__file__).resolve().parents[2]
12
+ MODULE_PATH = ROOT / "scripts" / "asset-generation-plan.py"
13
+
14
+
15
+ def load_module():
16
+ spec = importlib.util.spec_from_file_location("asset_generation_plan", MODULE_PATH)
17
+ if spec is None or spec.loader is None:
18
+ raise RuntimeError("cannot load asset generation planner")
19
+ module = importlib.util.module_from_spec(spec)
20
+ sys.modules[spec.name] = module
21
+ spec.loader.exec_module(module)
22
+ return module
23
+
24
+
25
+ MODULE = load_module()
26
+
27
+
28
+ def check(condition: bool, message: str) -> None:
29
+ if not condition:
30
+ raise AssertionError(message)
31
+
32
+
33
+ def request(canvas=(256, 448), frame_count=8, isolation="required", preferences=None):
34
+ value = {
35
+ "schema_version": "0.1",
36
+ "request_id": "test-request",
37
+ "asset_id": "test-hero-attack",
38
+ "asset_kind": "frame_sequence",
39
+ "target": {
40
+ "canvas": {"width": canvas[0], "height": canvas[1]},
41
+ "frame_count": frame_count,
42
+ "fps": 8,
43
+ "alpha_mode": "straight",
44
+ "pixel_art": True,
45
+ "anchor": "footline",
46
+ },
47
+ "generation_policy": {
48
+ "frame_isolation": isolation,
49
+ "allow_crop": False,
50
+ "allow_stretch": False,
51
+ "allow_silent_resize": False,
52
+ "allow_provider_batch_as_provisional": True,
53
+ "integer_scale_only": True,
54
+ },
55
+ "actions": [{"action_id": "attack", "positive_cues": ["sword swing"], "negative_cues": ["walk", "jump"]}],
56
+ }
57
+ if preferences is not None:
58
+ value["provider_preferences"] = preferences
59
+ return value
60
+
61
+
62
+ def verified_adapter(canvas=(256, 448)):
63
+ return {
64
+ "adapter_id": "verified.single-frame",
65
+ "kind": "external_provider",
66
+ "status": "verified",
67
+ "adapter_version": "0.1",
68
+ "invocation_mode": "api",
69
+ "cost_class": "external",
70
+ "inputs": ["reference"],
71
+ "outputs": ["frame_sequence"],
72
+ "compatibility": {"os": ["linux"]},
73
+ "availability": {"status": "available", "environment": "test"},
74
+ "capabilities": {
75
+ "canvas": {"shapes": ["portrait" if canvas[1] > canvas[0] else "landscape" if canvas[0] > canvas[1] else "square"], "allowed_sizes": [[canvas[0], canvas[1]]], "max_width": canvas[0], "max_height": canvas[1]},
76
+ "frame_behavior": {"mode": "single_frame", "single_frame": True, "max_frames_per_request": 1},
77
+ "adaptation": [],
78
+ },
79
+ "evidence": [{"path": "evidence.md", "sha256": "a" * 64, "kind": "static"}],
80
+ "limitations": [],
81
+ "risk_level": "low",
82
+ "side_effect_level": "read",
83
+ }
84
+
85
+
86
+ def registry_with(adapters, require_verified=True, allow_scaffold_only=False):
87
+ return {
88
+ "schema_version": "0.1",
89
+ "registry_id": "test-registry",
90
+ "generated_at": "2026-08-22T00:00:00Z",
91
+ "selection_policy": {"require_verified": require_verified, "allow_scaffold_only": allow_scaffold_only},
92
+ "adapters": adapters,
93
+ }
94
+
95
+
96
+ def by_id(plan, adapter_id):
97
+ return next(item for item in plan["providers"] if item["adapter_id"] == adapter_id)
98
+
99
+
100
+ def main() -> int:
101
+ production_registry = json.loads((ROOT / "artifact-adapter-registry.json").read_text())
102
+ plan_schema = json.loads((ROOT / "schemas" / "asset-generation-plan.schema.json").read_text())
103
+ check(plan_schema["properties"]["producer"]["const"] == "MotionLoom" and plan_schema["properties"]["schema_version"]["const"] == "0.2", "published plan schema must bind MotionLoom identity and version")
104
+ with tempfile.TemporaryDirectory() as td:
105
+ root = Path(td)
106
+ request_path = root / "request.json"
107
+ request_path.write_text(json.dumps(request()))
108
+ registry_path = root / "registry.json"
109
+ registry_path.write_text(json.dumps(production_registry))
110
+
111
+ plan = MODULE.build_plan(request(), production_registry, root)
112
+ check(plan["contract"] == "motionloom-asset-generation-plan" and plan["schema_version"] == "0.2", "plan contract must identify MotionLoom plan 0.2")
113
+ check(plan["producer"] == "MotionLoom" and plan["identity"]["product"] == "MotionLoom", "structured plan must preserve MotionLoom identity")
114
+ check(plan["approval"] is False and plan["production_approved"] is False, "planner must never grant approval")
115
+ check(plan["decision"] == "recommendations_available", "normal planning must remain useful without verified execution provider")
116
+ check(plan["execution_decision"] == "no_execution_eligible_route", "default verified-only execution must remain fail-closed")
117
+ check(plan["selection"]["eligible_count"] == 0, "default registry must have no eligible execution provider")
118
+ check(plan["selection"]["provisional_count"] >= 4, "scaffold/manual routes must remain visible as provisional")
119
+ check(plan["selection"]["recommendation_count"] >= 3, "normal planning must expose useful ranked recommendations")
120
+ check("fixture.local-artifact-intake" not in plan["selection"]["recommendation_adapter_ids"], "regression fixture must not be recommended as a generation route")
121
+ check(plan["recommendations"], "normal planning must not collapse to no provider available")
122
+ check(all(item["recommendation_status"] in {"recommended", "acceptable"} for item in plan["recommendations"]), "recommendation list must exclude not-recommended routes")
123
+ check(all(item["approval"] is False for item in plan["recommendations"]), "recommendations must preserve approval=false")
124
+ check(plan["agent_guidance"]["recommended_by"] == "MotionLoom", "plan-level guidance must identify MotionLoom")
125
+ check(plan["project"]["runtime"] is None and plan["project"]["framework"] is None, "synthetic test request without project context should remain explicit")
126
+ check(any("MotionLoom" in step for step in plan["next_steps"]), "next steps must use MotionLoom workflow identity")
127
+
128
+ pix = by_id(plan, "pixellab.animate-skeleton")
129
+ check(pix["recommendation_status"] == "recommended" and pix["execution_status"] == "provisional", "PixelLab may be recommended while execution remains provisional")
130
+ check(pix["execution_eligible"] is False, "PixelLab must not be execution-eligible under verified-only policy")
131
+ check(pix["canvas"]["status"] == "adaptation_required", "PixelLab skeleton must reject non-square native target")
132
+ check(pix["frames"]["status"] == "provisional_batch_only", "batch PixelLab output must remain provisional under required isolation")
133
+ pad = next(option for option in pix["adaptation_options"] if option["id"] == "deterministic-pad-to-target")
134
+ check(pad["source_canvas"] == [256, 256] and pad["target_canvas"] == [256, 448], "planner must propose explicit 256x256 to 256x448 padding")
135
+ check(pad["crop"] is False and pad["stretch"] is False, "planner must forbid crop and stretch")
136
+ check("action-separation" in pad["requires_validation"], "adaptation must require action separation validation")
137
+ check(pix["availability"]["status"] == "unknown", "PixelLab availability must not be fabricated")
138
+ check(pix["agent_guidance"]["recommended_by"] == "MotionLoom", "route guidance must identify MotionLoom")
139
+
140
+ manual = by_id(plan, "manual.import-frame-sequence")
141
+ check(manual["recommendation_status"] in {"recommended", "acceptable"}, "manual fallback must remain a normal planning option")
142
+ check(manual["execution_status"] == "provisional", "manual route remains provisional without runtime evidence")
143
+ check(manual["availability"]["status"] == "known", "manual route availability should be explicit")
144
+
145
+ create_text = by_id(plan, "pixellab.create-animated-object-character")
146
+ check(create_text["frames"]["frame_count_policy"] == "depends_on_canvas_size", "PixelLab create-from-text frame policy must be dynamic")
147
+ create_text_adapter = next(item for item in production_registry["adapters"] if item["adapter_id"] == "pixellab.create-animated-object-character")
148
+ small_canvas_frames = MODULE.frame_assessment(create_text_adapter, MODULE.target_summary(request(canvas=(128, 128), frame_count=4)), request()["generation_policy"])
149
+ check(small_canvas_frames["max_frames_per_request"] == 4 and small_canvas_frames["limits_by_canvas"], "planner must resolve declared dynamic frame limit for an exact PixelLab canvas")
150
+ existing_text_adapter = next(item for item in production_registry["adapters"] if item["adapter_id"] == "pixellab.animate-with-text")
151
+ existing_text = by_id(plan, "pixellab.animate-with-text")
152
+ check(existing_text["frames"]["frame_count_policy"] == "fixed" and existing_text["frames"]["max_frames_per_request"] == 4, "PixelLab animate-existing-reference route must model its documented four-frame output")
153
+
154
+ preferred_request = request(preferences={"preferred_adapter_ids": ["pixellab.animate-skeleton"]})
155
+ preferred_plan = MODULE.build_plan(preferred_request, production_registry, root)
156
+ preferred_pix = by_id(preferred_plan, "pixellab.animate-skeleton")
157
+ check(preferred_pix["user_preference"] == {"state": "preferred", "requested": True}, "explicit user preference must be visible")
158
+ check(preferred_pix["recommendation_status"] == "recommended", "preferred provisional provider may be recommended")
159
+ check(preferred_pix["execution_status"] == "provisional" and preferred_pix["execution_eligible"] is False, "preference must not promote provisional execution")
160
+ check(any("preferred by the user" in reason for reason in preferred_pix["rationale"]), "rationale must explain preferred route")
161
+ check(preferred_pix["ranking_score"] >= 8 and any(item["adapter_id"] == "pixellab.animate-skeleton" for item in preferred_plan["recommendations"]), "preferred compatible provisional route should receive a strong explainable ranking signal without overriding a safer native route")
162
+
163
+ incompatible_request = request(preferences={"preferred_adapter_ids": ["internal.imagegen"]})
164
+ incompatible_plan = MODULE.build_plan(incompatible_request, production_registry, root)
165
+ incompatible = by_id(incompatible_plan, "internal.imagegen")
166
+ check(incompatible["user_preference"]["state"] == "preferred", "incompatible preference must remain visible")
167
+ check(incompatible["recommendation_status"] == "not_recommended" and incompatible["execution_status"] == "blocked", "hard-incompatible preferred route must be blocked")
168
+ check("internal.imagegen" not in incompatible_plan["selection"]["recommendation_adapter_ids"], "incompatible preferred route must not be recommended")
169
+ check(incompatible_plan["recommendations"], "safer alternatives must remain available")
170
+ check(any("does not declare canvas capability" in reason for reason in incompatible["rationale"]), "incompatibility rationale must explain missing canvas capability")
171
+
172
+ excluded_request = request(preferences={"excluded_adapter_ids": ["manual.import-frame-sequence"]})
173
+ excluded_plan = MODULE.build_plan(excluded_request, production_registry, root)
174
+ excluded = by_id(excluded_plan, "manual.import-frame-sequence")
175
+ check(excluded["user_preference"]["state"] == "excluded" and excluded["recommendation_status"] == "not_recommended", "excluded preference must be honored without affecting other routes")
176
+ check("manual.import-frame-sequence" not in excluded_plan["selection"]["recommendation_adapter_ids"], "excluded route must not be recommended")
177
+
178
+ unavailable_registry = registry_with([copy.deepcopy(next(item for item in production_registry["adapters"] if item["adapter_id"] == "pixellab.animate-skeleton"))], require_verified=False, allow_scaffold_only=True)
179
+ unavailable_registry["adapters"][0]["availability"] = {"status": "unavailable", "reason": "connector not configured"}
180
+ unavailable_plan = MODULE.build_plan(request(), unavailable_registry, root)
181
+ unavailable = unavailable_plan["providers"][0]
182
+ check(unavailable["recommendation_status"] == "recommended" and unavailable["execution_status"] == "blocked", "unavailable compatible route may remain a recommendation but cannot execute")
183
+ check(unavailable_plan["recommendations"][0]["route"] == "resolve_availability_first", "unavailable route must instruct the Agent to resolve availability")
184
+
185
+ unknown_preference = request(preferences={"preferred_adapter_ids": ["future.provider"]})
186
+ unknown_plan = MODULE.build_plan(unknown_preference, production_registry, root)
187
+ check(unknown_plan["selection"]["unknown_preferred_adapter_ids"] == ["future.provider"], "unknown preferred provider must be surfaced without fabrication")
188
+ check(any("future.provider" in warning for warning in unknown_plan["warnings"]), "unknown preference warning must be human-readable")
189
+
190
+ cli = subprocess.run([
191
+ sys.executable, str(MODULE_PATH), "plan", "--request", str(request_path), "--registry", str(registry_path), "--project-root", str(root), "--json", "--strict"
192
+ ], capture_output=True, text=True)
193
+ strict_output = json.loads(cli.stdout)
194
+ check(cli.returncode == 2 and strict_output["execution_decision"] == "no_execution_eligible_route", "strict mode must fail without execution-eligible provider")
195
+
196
+ human = subprocess.run([
197
+ sys.executable, str(MODULE_PATH), "plan", "--request", str(request_path), "--registry", str(registry_path), "--project-root", str(root)
198
+ ], capture_output=True, text=True)
199
+ check(human.returncode == 0 and "MotionLoom Project Assessment" in human.stdout and "MotionLoom Recommendations" in human.stdout and "MotionLoom Agent Guidance" in human.stdout, "human CLI must expose MotionLoom identity and guidance")
200
+
201
+ permitted = copy.deepcopy(production_registry)
202
+ permitted["selection_policy"] = {"require_verified": False, "allow_scaffold_only": True}
203
+ next(item for item in permitted["adapters"] if item["adapter_id"] == "pixellab.animate-skeleton")["availability"] = {"status": "available", "environment": "test-connector"}
204
+ permitted_plan = MODULE.build_plan(request(), permitted, root)
205
+ permitted_pix = by_id(permitted_plan, "pixellab.animate-skeleton")
206
+ check(permitted_pix["execution_status"] == "provisional" and permitted_pix["execution_eligible"] is True, "policy may authorize scaffold execution while preserving provisional status")
207
+ check(any(item["adapter_id"] == "pixellab.animate-skeleton" for item in permitted_plan["recommendations"]), "permitted scaffold provider may be ranked")
208
+
209
+ verified = verified_adapter()
210
+ verified_plan = MODULE.build_plan(request(), registry_with([verified]), root)
211
+ check(verified_plan["decision"] == "compatible_execution_route_available", "verified native provider must produce execution route")
212
+ check(verified_plan["selection"]["eligible_adapter_ids"] == ["verified.single-frame"], "verified provider must be execution-eligible")
213
+ check(verified_plan["recommendations"][0]["execution_status"] == "verified" and verified_plan["recommendations"][0]["route"] == "use_native", "verified native route must be recommended with verified status")
214
+
215
+ unknown = copy.deepcopy(production_registry)
216
+ unknown["adapters"] = [next(item for item in production_registry["adapters"] if item["adapter_id"] == "internal.imagegen")]
217
+ unknown_plan = MODULE.build_plan(request(), unknown, root)
218
+ unknown_item = unknown_plan["providers"][0]
219
+ check(unknown_plan["decision"] == "no_safe_recommendation_available", "unknown-only provider set must not produce a false recommendation")
220
+ check(unknown_item["execution_status"] == "blocked" and any("does not declare canvas capability" in warning for warning in unknown_item["warnings"]), "unknown capabilities must be blocked and surfaced")
221
+
222
+ oversized = copy.deepcopy(next(item for item in production_registry["adapters"] if item["adapter_id"] == "pixellab.animate-skeleton"))
223
+ oversized["adapter_id"] = "provider.oversized-square"
224
+ oversized["capabilities"]["canvas"]["allowed_sizes"] = [[384, 384]]
225
+ oversized_plan = MODULE.build_plan(request(), registry_with([oversized], require_verified=False, allow_scaffold_only=True), root)
226
+ oversized_assessment = oversized_plan["providers"][0]
227
+ check(oversized_assessment["execution_status"] == "blocked", "source exceeding target width must be blocked")
228
+ check(not oversized_assessment["adaptation_options"] and "no declared safe canvas adaptation strategy" in oversized_assessment["hard_failures"], "384x384 source must not be recommended for 256x448 padding")
229
+
230
+ landscape_plan = MODULE.build_plan(request(canvas=(448, 256), frame_count=2), registry_with([existing_text_adapter], require_verified=False, allow_scaffold_only=True), root)
231
+ landscape = landscape_plan["providers"][0]
232
+ check(any(option["target_canvas"] == [448, 256] for option in landscape["adaptation_options"]), "landscape target must receive explicit adaptation assessment")
233
+
234
+ request_path.write_text(json.dumps(request(canvas=(128, 128), frame_count=1)))
235
+ contextual = copy.deepcopy(request())
236
+ contextual["project_context"] = {"runtime": "game-runtime", "framework": "canvas", "existing_assets": ["assets/hero.png"], "rig_requirements": ["footline"], "provenance_requirements": ["receipt"], "validation_requirements": ["MotionLoom Dev Lab"]}
237
+ contextual_plan = MODULE.build_plan(contextual, production_registry, root)
238
+ check(contextual_plan["project"]["runtime"] == "game-runtime" and contextual_plan["project"]["framework"] == "canvas", "project context must flow into MotionLoom assessment")
239
+ check(contextual_plan["project"]["requested_existing_assets"] == ["assets/hero.png"] and contextual_plan["project"]["validation_requirements"] == ["MotionLoom Dev Lab"], "project asset and validation requirements must remain visible")
240
+
241
+ cli_native = subprocess.run([
242
+ sys.executable, str(MODULE_PATH), "plan", "--request", str(request_path), "--registry", str(registry_path), "--project-root", str(root), "--json"
243
+ ], capture_output=True, text=True)
244
+ check(cli_native.returncode == 0 and json.loads(cli_native.stdout)["approval"] is False, "CLI must preserve approval=false")
245
+ print("asset generation planner contract tests: PASS")
246
+ return 0
247
+
248
+
249
+ if __name__ == "__main__":
250
+ raise SystemExit(main())
@@ -6,6 +6,8 @@ from __future__ import annotations
6
6
  import base64
7
7
  import hashlib
8
8
  import json
9
+ import os
10
+ import shutil
9
11
  import subprocess
10
12
  import sys
11
13
  import tempfile
@@ -134,19 +136,33 @@ def main() -> int:
134
136
 
135
137
  scene_dir = ROOT / "src" / "output" / "browser-review-smoke"
136
138
  task_dir = ROOT / "artifacts" / "browser-review-smoke-task"
139
+ smoke_task_dir = temp / "professional-review-e2e-attestation"
140
+ shutil.copytree(task_dir, smoke_task_dir)
141
+ shutil.copy(
142
+ ROOT / "artifacts" / "browser-review-smoke-task" / "project-context.json",
143
+ smoke_task_dir / "project-context.json",
144
+ )
145
+ shutil.copy(
146
+ ROOT / "artifacts" / "browser-review-smoke-task" / "evidence-verifier-report.json",
147
+ smoke_task_dir / "evidence-verifier-report.json",
148
+ )
149
+ candidate_path = smoke_task_dir / "browser-review.json"
150
+ candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
151
+ candidate["expires_at"] = "2099-01-01T00:00:00Z"
152
+ candidate_path.write_text(json.dumps(candidate, indent=2) + "\n", encoding="utf-8")
137
153
  task_statement = make_statement(
138
154
  task_id="browser-review-smoke-task",
139
155
  scene="browser-review-smoke",
140
- context_hash=sha256_file(task_dir / "project-context.json"),
156
+ context_hash=sha256_file(smoke_task_dir / "project-context.json"),
141
157
  source_sha256=sha256_file(scene_dir / "animation.json"),
142
158
  manifest_sha256=sha256_file(scene_dir / "manifest.json"),
143
- motion_ir_sha256=sha256_file(task_dir / "motion-ir.json"),
159
+ motion_ir_sha256=sha256_file(smoke_task_dir / "motion-ir.json"),
144
160
  evidence={
145
- "runtime_evidence_sha256": sha256_file(task_dir / "runtime-adapters" / "runtime-evidence.json"),
146
- "runtime_telemetry_sha256": telemetry_bundle_sha256(task_dir),
147
- "verifier_report_sha256": sha256_file(task_dir / "evidence-verifier-report.json"),
161
+ "runtime_evidence_sha256": sha256_file(smoke_task_dir / "runtime-adapters" / "runtime-evidence.json"),
162
+ "runtime_telemetry_sha256": telemetry_bundle_sha256(smoke_task_dir),
163
+ "verifier_report_sha256": sha256_file(smoke_task_dir / "evidence-verifier-report.json"),
148
164
  },
149
- provenance_chain_hash=sha256_file(task_dir / "provenance.json"),
165
+ provenance_chain_hash=sha256_file(smoke_task_dir / "provenance.json"),
150
166
  )
151
167
  smoke_statement_file = temp / "smoke-statement.json"
152
168
  smoke_statement_file.write_text(json.dumps(task_statement, indent=2) + "\n", encoding="utf-8")
@@ -157,8 +173,8 @@ def main() -> int:
157
173
  sys.executable,
158
174
  str(ROOT / "scripts" / "quality-gate.py"),
159
175
  "--scene", "browser-review-smoke",
160
- "--context", str(task_dir / "project-context.json"),
161
- "--task-dir", str(task_dir),
176
+ "--context", str(smoke_task_dir / "project-context.json"),
177
+ "--task-dir", str(smoke_task_dir),
162
178
  "--require-attestation",
163
179
  "--attestation", str(smoke_bundle_file),
164
180
  "--trust-policy", str(policy_file),