motionloom 2.3.0 → 2.5.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 (113) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +97 -11
  3. package/ROADMAP.md +8 -5
  4. package/SKILL.md +24 -5
  5. package/agent-card.json +34 -5
  6. package/agent-surfaces.json +1 -1
  7. package/artifact-adapter-registry.json +53 -0
  8. package/bin/motionloom.mjs +43 -6
  9. package/capability-registry.json +308 -0
  10. package/docs/CHECKLIST.md +16 -0
  11. package/docs/STATUS.md +2 -2
  12. package/docs/apple-distribution.md +24 -0
  13. package/docs/architecture/apple/cloudkit.md +18 -0
  14. package/docs/architecture/apple/contracts.md +9 -0
  15. package/docs/architecture/apple/implementation-status-2026-08-16.md +55 -0
  16. package/docs/architecture/apple/studio.md +35 -0
  17. package/docs/audits/data/deep-stress-latest.json +65 -65
  18. package/docs/releases/2.4.0.md +31 -0
  19. package/docs/releases/2.5.0.md +31 -0
  20. package/docs/releases/npm-publish-from-workstation.md +19 -8
  21. package/docs/research/agent-skill-ecosystem-notes.md +47 -0
  22. package/docs/research/ai-animation-tools-2026-notes.md +81 -0
  23. package/docs/research/ai-animation-tools-2026-report.md +168 -0
  24. package/docs/research/ai-pilot-ingest-notes.md +29 -0
  25. package/docs/research/ai-repository-benchmark-2026-08-16.md +75 -0
  26. package/examples/agent-consumer/ai-pilot-scout/CHATGPT-HANDOFF.md +70 -0
  27. package/examples/agent-consumer/ai-pilot-scout/CODEX-HANDOFF.md +80 -0
  28. package/examples/agent-consumer/ai-pilot-scout/README.md +39 -0
  29. package/examples/agent-consumer/ai-pilot-scout/chatgpt-geometry-correction-prompt.md +55 -0
  30. package/examples/agent-consumer/ai-pilot-scout/chatgpt-pose-generation-prompt.md +78 -0
  31. package/examples/agent-consumer/ai-pilot-scout/partial-handoff.json +73 -0
  32. package/examples/agent-consumer/artifact-intake/hero-motion-controls.json +15 -0
  33. package/examples/agent-consumer/artifact-intake/hero-motion-export.json +14 -0
  34. package/examples/agent-consumer/artifact-intake/hero-motion-provenance.json +20 -0
  35. package/examples/agent-consumer/artifact-intake/hero-motion-receipt.json +16 -0
  36. package/examples/agent-consumer/asset-consistency/assets/forest-back.png +0 -0
  37. package/examples/agent-consumer/asset-consistency/assets/forest-front.png +0 -0
  38. package/examples/agent-consumer/asset-consistency/assets/forest-mid.png +0 -0
  39. package/examples/agent-consumer/asset-consistency/assets/hero-atlas.png +0 -0
  40. package/examples/agent-consumer/asset-consistency/assets/hero-frame-00.png +0 -0
  41. package/examples/agent-consumer/asset-consistency/assets/hero-frame-01.png +0 -0
  42. package/examples/agent-consumer/asset-consistency/assets/hero-frame-02.png +0 -0
  43. package/examples/agent-consumer/asset-consistency/assets/hero-frame-03.png +0 -0
  44. package/examples/agent-consumer/asset-consistency/forest-layered-map.json +53 -0
  45. package/examples/agent-consumer/asset-consistency/hero-atlas-contract.json +24 -0
  46. package/examples/agent-consumer/asset-consistency/hero-identity.json +51 -0
  47. package/examples/agent-consumer/asset-consistency/hero-walk-action-set.json +27 -0
  48. package/examples/agent-consumer/asset-consistency/hero-walk-frame-geometry.json +63 -0
  49. package/examples/agent-consumer/rig-compatibility/hero-walk-fixture-rig.json +13 -0
  50. package/examples/agent-consumer/rive-package-gate/README.md +20 -0
  51. package/examples/agent-consumer/runtime-candidate/hero-walk-candidate.json +18 -0
  52. package/examples/agent-consumer/runtime-pilot/action-set.json +19 -0
  53. package/examples/agent-consumer/runtime-pilot/asset-identity.json +44 -0
  54. package/examples/agent-consumer/runtime-pilot/candidate.json +21 -0
  55. package/examples/agent-consumer/runtime-pilot/controls.json +35 -0
  56. package/examples/agent-consumer/runtime-pilot/export.json +17 -0
  57. package/examples/agent-consumer/runtime-pilot/provenance.json +38 -0
  58. package/examples/agent-consumer/runtime-pilot/receipt.json +30 -0
  59. package/package.json +58 -4
  60. package/references/browser-review-contract.md +7 -1
  61. package/references/intelligence-core.md +22 -1
  62. package/rig-adapter-registry.json +39 -0
  63. package/schemas/action-set.schema.json +42 -0
  64. package/schemas/artifact-adapter-registry.schema.json +16 -0
  65. package/schemas/asset-identity.schema.json +117 -0
  66. package/schemas/asset-provenance.schema.json +1 -1
  67. package/schemas/atlas-contract.schema.json +48 -0
  68. package/schemas/control-track.schema.json +18 -0
  69. package/schemas/export-manifest.schema.json +19 -0
  70. package/schemas/frame-geometry.schema.json +79 -0
  71. package/schemas/generation-receipt.schema.json +20 -0
  72. package/schemas/layered-map.schema.json +75 -0
  73. package/schemas/rig-adapter-registry.schema.json +14 -0
  74. package/schemas/rig-compatibility.schema.json +53 -0
  75. package/schemas/rive-package-manifest.schema.json +43 -0
  76. package/schemas/runtime-candidate.schema.json +48 -0
  77. package/schemas/scene-manifest.schema.json +18 -0
  78. package/scripts/artifact-intake.py +408 -0
  79. package/scripts/asset-consistency.py +517 -0
  80. package/scripts/asset-provenance.py +8 -3
  81. package/scripts/attestation.py +8 -0
  82. package/scripts/build-ai-pilot.py +504 -0
  83. package/scripts/evidence-verifier.py +18 -9
  84. package/scripts/intelligence.py +85 -18
  85. package/scripts/isolate-alpha-background.py +147 -0
  86. package/scripts/quality-gate.py +88 -2
  87. package/scripts/report.py +85 -0
  88. package/scripts/resolve-task-bundle.py +84 -0
  89. package/scripts/review-hook.py +26 -4
  90. package/scripts/rig-compatibility.py +251 -0
  91. package/scripts/rive-package-gate.py +245 -0
  92. package/scripts/runtime-candidate.py +301 -0
  93. package/scripts/setup.mjs +54 -26
  94. package/src/output/runtime-pilot-framer/asset-provenance.json +38 -0
  95. package/src/output/runtime-pilot-framer/browser-review.json +20 -0
  96. package/src/output/runtime-pilot-framer/framer-motion/runtime-telemetry.json +103 -0
  97. package/src/output/runtime-pilot-framer/manifest.json +31 -0
  98. package/src/output/runtime-pilot-framer/motion-spec.json +25 -0
  99. package/src/output/runtime-pilot-framer/runtime-evidence.json +96 -0
  100. package/src/output/runtime-pilot-framer/runtime-telemetry.json +100 -0
  101. package/src/output/runtime-pilot-framer/scene.jsx +52 -0
  102. package/src/output/runtime-pilot-framer/snapshot/.render-meta.json +9 -0
  103. package/src/output/runtime-pilot-framer/snapshot/frame-00.png +0 -0
  104. package/src/output/runtime-pilot-framer/snapshot/frame-100.png +0 -0
  105. package/src/output/runtime-pilot-framer/snapshot/frame-50.png +0 -0
  106. package/tests/scripts/run_tests.py +151 -0
  107. package/tests/scripts/test_ai_pilot_builder.py +128 -0
  108. package/tests/scripts/test_alpha_isolation.py +60 -0
  109. package/tests/scripts/test_artifact_intake.py +146 -0
  110. package/tests/scripts/test_asset_consistency.py +199 -0
  111. package/tests/scripts/test_rig_compatibility.py +105 -0
  112. package/tests/scripts/test_rive_package_gate.py +101 -0
  113. package/tests/scripts/test_runtime_candidate.py +95 -0
@@ -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,147 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministically isolate a high-contrast, edge-connected flat background.
3
+
4
+ This is a narrow recovery utility for generated PNGs where an image provider
5
+ painted a dark background despite a transparency request. It flood-fills only
6
+ edge-connected pixels within a stated RGB-distance of an observed edge color.
7
+ It does not claim the input was provider-native alpha, alter provenance, or
8
+ approve an asset. Review the JSON report before using the result in a bundle.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import hashlib
15
+ import importlib.util
16
+ import json
17
+ import struct
18
+ import sys
19
+ import zlib
20
+ from collections import deque
21
+ from pathlib import Path
22
+ from statistics import median
23
+ from typing import Any
24
+
25
+
26
+ ROOT = Path(__file__).resolve().parents[1]
27
+ PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
28
+
29
+
30
+ def digest(path: Path) -> str:
31
+ return hashlib.sha256(path.read_bytes()).hexdigest()
32
+
33
+
34
+ def load_png_reader() -> Any:
35
+ source = ROOT / "scripts" / "asset-consistency.py"
36
+ spec = importlib.util.spec_from_file_location("motionloom_alpha_isolation_png", source)
37
+ if spec is None or spec.loader is None:
38
+ raise RuntimeError(f"could not load PNG reader: {source}")
39
+ module = importlib.util.module_from_spec(spec)
40
+ sys.modules[spec.name] = module
41
+ spec.loader.exec_module(module)
42
+ return module.PNGImage
43
+
44
+
45
+ def median_edge_color(pixels: list[tuple[int, int, int, int]], width: int, height: int) -> tuple[int, int, int]:
46
+ indices = set(range(width)) | {width * (height - 1) + x for x in range(width)}
47
+ indices.update(y * width for y in range(height))
48
+ indices.update(y * width + width - 1 for y in range(height))
49
+ values = [pixels[index] for index in sorted(indices)]
50
+ return tuple(int(median([pixel[channel] for pixel in values])) for channel in range(3))
51
+
52
+
53
+ def distance(pixel: tuple[int, int, int, int], color: tuple[int, int, int]) -> int:
54
+ return sum(abs(pixel[index] - color[index]) for index in range(3))
55
+
56
+
57
+ def isolate(pixels: list[tuple[int, int, int, int]], width: int, height: int, background: tuple[int, int, int], tolerance: int) -> set[int]:
58
+ def matches(index: int) -> bool:
59
+ return pixels[index][3] > 0 and distance(pixels[index], background) <= tolerance
60
+ edges = set(range(width)) | {width * (height - 1) + x for x in range(width)}
61
+ edges.update(y * width for y in range(height))
62
+ edges.update(y * width + width - 1 for y in range(height))
63
+ queue = deque(index for index in edges if matches(index))
64
+ visited = set(queue)
65
+ while queue:
66
+ index = queue.popleft()
67
+ x, y = index % width, index // width
68
+ for neighbour in (index - 1 if x else None, index + 1 if x + 1 < width else None, index - width if y else None, index + width if y + 1 < height else None):
69
+ if neighbour is not None and neighbour not in visited and matches(neighbour):
70
+ visited.add(neighbour)
71
+ queue.append(neighbour)
72
+ return visited
73
+
74
+
75
+ def png_rgba(width: int, height: int, pixels: list[tuple[int, int, int, int]]) -> bytes:
76
+ raw = bytearray()
77
+ for y in range(height):
78
+ raw.append(0)
79
+ for pixel in pixels[y * width : (y + 1) * width]:
80
+ raw.extend(pixel)
81
+ def chunk(kind: bytes, content: bytes) -> bytes:
82
+ return struct.pack(">I", len(content)) + kind + content + struct.pack(">I", zlib.crc32(kind + content) & 0xFFFFFFFF)
83
+ return PNG_SIGNATURE + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(bytes(raw), level=9)) + chunk(b"IEND", b"")
84
+
85
+
86
+ def main(argv: list[str] | None = None) -> int:
87
+ parser = argparse.ArgumentParser(description="Flood-fill a flat edge-connected background into alpha=0")
88
+ parser.add_argument("input", type=Path)
89
+ parser.add_argument("output", type=Path)
90
+ parser.add_argument("--report", type=Path, help="Optional JSON evidence report")
91
+ parser.add_argument("--tolerance", type=int, default=24, help="Manhattan RGB distance from edge background; 0-765")
92
+ parser.add_argument("--allow-edge-foreground", action="store_true", help="Permit opaque pixels on the canvas edge; only use for intentionally edge-cropped art")
93
+ args = parser.parse_args(argv)
94
+ if not 0 <= args.tolerance <= 765:
95
+ raise SystemExit("--tolerance must be in 0..765")
96
+ if not args.input.is_file():
97
+ raise SystemExit(f"input is missing: {args.input}")
98
+ if args.output.resolve() == args.input.resolve():
99
+ raise SystemExit("output must differ from input")
100
+ PNGImage = load_png_reader()
101
+ image = PNGImage(args.input)
102
+ pixels = image.rgba()
103
+ background = median_edge_color(pixels, image.width, image.height)
104
+ removed = isolate(pixels, image.width, image.height, background, args.tolerance)
105
+ if not removed:
106
+ raise SystemExit("no edge-connected background pixels matched; do not use a larger tolerance without visual review")
107
+ output_pixels = [(r, g, b, 0 if index in removed else a) for index, (r, g, b, a) in enumerate(pixels)]
108
+ foreground = sum(1 for _, _, _, alpha in output_pixels if alpha > 0)
109
+ if foreground == 0:
110
+ raise SystemExit("isolation would remove all pixels; lower tolerance or provide a different source")
111
+ edge_indices = set(range(image.width)) | {image.width * (image.height - 1) + x for x in range(image.width)}
112
+ edge_indices.update(y * image.width for y in range(image.height))
113
+ edge_indices.update(y * image.width + image.width - 1 for y in range(image.height))
114
+ residual_edge_pixels = sum(1 for index in edge_indices if output_pixels[index][3] > 0)
115
+ if residual_edge_pixels and not args.allow_edge_foreground:
116
+ raise SystemExit(
117
+ f"{residual_edge_pixels} opaque pixel(s) remain at the canvas edge after isolation; "
118
+ "this is likely residual background contamination. Use a cleaner source, or explicitly review and pass --allow-edge-foreground for intentionally cropped art."
119
+ )
120
+ args.output.parent.mkdir(parents=True, exist_ok=True)
121
+ args.output.write_bytes(png_rgba(image.width, image.height, output_pixels))
122
+ report = {
123
+ "contract": "alpha-isolation-report",
124
+ "mode": "edge_connected_flat_background",
125
+ "input": str(args.input),
126
+ "input_sha256": digest(args.input),
127
+ "output": str(args.output),
128
+ "output_sha256": digest(args.output),
129
+ "canvas": {"width": image.width, "height": image.height},
130
+ "observed_edge_background_rgb": list(background),
131
+ "tolerance": args.tolerance,
132
+ "removed_pixels": len(removed),
133
+ "foreground_alpha_pixels": foreground,
134
+ "residual_edge_foreground_pixels": residual_edge_pixels,
135
+ "edge_foreground_allowed": args.allow_edge_foreground,
136
+ "human_review_required": True,
137
+ "production_approved": False,
138
+ }
139
+ if args.report:
140
+ args.report.parent.mkdir(parents=True, exist_ok=True)
141
+ args.report.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
142
+ print(json.dumps(report, indent=2, sort_keys=True))
143
+ return 0
144
+
145
+
146
+ if __name__ == "__main__":
147
+ raise SystemExit(main())
@@ -63,6 +63,24 @@ def _load_asset_provenance():
63
63
  return module
64
64
 
65
65
 
66
+ def _load_asset_consistency():
67
+ path = ROOT / "scripts" / "asset-consistency.py"
68
+ loader = importlib.util.spec_from_file_location("asset_consistency", path)
69
+ module = importlib.util.module_from_spec(loader)
70
+ sys.modules[loader.name] = module
71
+ loader.loader.exec_module(module)
72
+ return module
73
+
74
+
75
+ def _load_artifact_intake():
76
+ path = ROOT / "scripts" / "artifact-intake.py"
77
+ loader = importlib.util.spec_from_file_location("artifact_intake", path)
78
+ module = importlib.util.module_from_spec(loader)
79
+ sys.modules[loader.name] = module
80
+ loader.loader.exec_module(module)
81
+ return module
82
+
83
+
66
84
  def _json(path: Path):
67
85
  try:
68
86
  return json.loads(path.read_text(encoding="utf-8"))
@@ -82,7 +100,7 @@ def _telemetry_bundle_sha256(task_dir: Path) -> str:
82
100
  return hashlib.sha256(json.dumps(entries, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
83
101
 
84
102
 
85
- def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = False, task_dir: Path | None = None, require_intelligence: bool = False, require_p1: bool = False, require_benchmark: bool = False, require_telemetry: bool = False, require_attestation: bool = False, attestation_path: Path | None = None, trust_policy_path: Path | None = None, require_visual_truth: bool = False, require_asset_provenance: bool = False, asset_provenance_path: Path | None = None) -> list[str]:
103
+ def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = False, task_dir: Path | None = None, require_intelligence: bool = False, require_p1: bool = False, require_benchmark: bool = False, require_telemetry: bool = False, require_attestation: bool = False, attestation_path: Path | None = None, trust_policy_path: Path | None = None, require_visual_truth: bool = False, require_asset_provenance: bool = False, asset_provenance_path: Path | None = None, require_asset_consistency: bool = False, asset_consistency_path: Path | None = None, require_artifact_intake: bool = False) -> list[str]:
86
104
  issues = []
87
105
  manifest_path = scene_dir / "manifest.json"
88
106
  spec_path = scene_dir / "motion-spec.json"
@@ -138,6 +156,66 @@ def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = F
138
156
  except (OSError, ValueError, AttributeError) as exc:
139
157
  issues.append(f"asset provenance contract: {exc}")
140
158
 
159
+ consistency_name = manifest.get("consistency_ref")
160
+ if require_asset_consistency and not isinstance(consistency_name, str) and asset_consistency_path is None:
161
+ issues.append("asset consistency gate requires manifest.consistency_ref")
162
+ if consistency_name or asset_consistency_path:
163
+ resolved_consistency = asset_consistency_path or (scene_dir / str(consistency_name)).resolve()
164
+ if scene_dir.resolve() not in resolved_consistency.parents or not resolved_consistency.is_file():
165
+ issues.append("manifest.consistency_ref must point to an existing file inside the scene directory")
166
+ else:
167
+ try:
168
+ consistency_kind = manifest.get("consistency_kind", "frame-geometry")
169
+ consistency_module = _load_asset_consistency()
170
+ consistency_document = _json(resolved_consistency)
171
+ consistency_result = consistency_module.run(
172
+ consistency_kind,
173
+ consistency_document,
174
+ scene_dir,
175
+ strict=require_asset_consistency,
176
+ )
177
+ issues.extend(
178
+ f"asset consistency: {item.get('code', 'issue')} — {item.get('message', 'contract violation')}"
179
+ for item in consistency_result.get("errors", [])
180
+ )
181
+ if require_asset_consistency and not consistency_result.get("ready"):
182
+ issues.append("asset consistency contract is not ready")
183
+ except (OSError, ValueError, AttributeError, KeyError, TypeError) as exc:
184
+ issues.append(f"asset consistency contract: {exc}")
185
+
186
+ intake = manifest.get("artifact_intake")
187
+ if require_artifact_intake and not isinstance(intake, dict):
188
+ issues.append("artifact intake gate requires manifest.artifact_intake")
189
+ if intake is not None:
190
+ required_intake_keys = ("registry", "receipt", "controls", "export_manifest")
191
+ if not isinstance(intake, dict) or any(not isinstance(intake.get(key), str) for key in required_intake_keys):
192
+ issues.append("manifest.artifact_intake requires registry, receipt, controls and export_manifest relative paths")
193
+ else:
194
+ resolved_intake = {key: (scene_dir / intake[key]).resolve() for key in required_intake_keys}
195
+ if any(scene_dir.resolve() not in path.parents or not path.is_file() for path in resolved_intake.values()):
196
+ issues.append("manifest.artifact_intake paths must point to existing files inside the scene directory")
197
+ else:
198
+ try:
199
+ intake_module = _load_artifact_intake()
200
+ intake_result = intake_module.evaluate_bundle(
201
+ argparse.Namespace(
202
+ root=scene_dir,
203
+ registry=resolved_intake["registry"],
204
+ receipt=resolved_intake["receipt"],
205
+ controls=resolved_intake["controls"],
206
+ export_manifest=resolved_intake["export_manifest"],
207
+ strict=require_artifact_intake,
208
+ )
209
+ )
210
+ issues.extend(
211
+ f"artifact intake: {item.get('code', 'issue')} — {item.get('message', 'contract violation')}"
212
+ for item in intake_result.get("errors", [])
213
+ )
214
+ if require_artifact_intake and not intake_result.get("ready"):
215
+ issues.append("artifact intake contract is not runtime-ready")
216
+ except (OSError, ValueError, AttributeError, KeyError, TypeError) as exc:
217
+ issues.append(f"artifact intake contract: {exc}")
218
+
141
219
  checks = manifest.get("checks")
142
220
  if not isinstance(checks, list) or not checks:
143
221
  issues.append("manifest.checks must contain the Dev Lab quality checklist")
@@ -414,6 +492,9 @@ def main() -> int:
414
492
  parser.add_argument("--require-visual-truth", action="store_true")
415
493
  parser.add_argument("--require-asset-provenance", action="store_true")
416
494
  parser.add_argument("--asset-provenance")
495
+ parser.add_argument("--require-asset-consistency", action="store_true")
496
+ parser.add_argument("--asset-consistency")
497
+ parser.add_argument("--require-artifact-intake", action="store_true")
417
498
  parser.add_argument("--attestation")
418
499
  parser.add_argument("--trust-policy")
419
500
  args = parser.parse_args()
@@ -429,13 +510,14 @@ def main() -> int:
429
510
  attestation_path = Path(args.attestation).resolve() if args.attestation else None
430
511
  trust_policy_path = Path(args.trust_policy).resolve() if args.trust_policy else None
431
512
  asset_provenance_path = Path(args.asset_provenance).resolve() if args.asset_provenance else None
513
+ asset_consistency_path = Path(args.asset_consistency).resolve() if args.asset_consistency else None
432
514
  scenes = [root / "src" / "output" / args.scene] if args.scene else sorted(p for p in output_root.iterdir() if p.is_dir()) if output_root.exists() else []
433
515
  if not scenes:
434
516
  print("QUALITY GATE: no scene outputs found")
435
517
  return 0
436
518
  failed = False
437
519
  for scene_dir in scenes:
438
- issues = validate_scene(scene_dir, context, args.require_browser_review, task_dir, args.require_intelligence, args.require_p1, args.require_benchmark, args.require_telemetry, args.require_attestation, attestation_path, trust_policy_path, args.require_visual_truth, args.require_asset_provenance, asset_provenance_path)
520
+ issues = validate_scene(scene_dir, context, args.require_browser_review, task_dir, args.require_intelligence, args.require_p1, args.require_benchmark, args.require_telemetry, args.require_attestation, attestation_path, trust_policy_path, args.require_visual_truth, args.require_asset_provenance, asset_provenance_path, args.require_asset_consistency, asset_consistency_path, args.require_artifact_intake)
439
521
  if issues:
440
522
  failed = True
441
523
  print(f"REJECTED {scene_dir.name}:")
@@ -447,6 +529,10 @@ def main() -> int:
447
529
  suffixes.append("visual-truth contract")
448
530
  if args.require_asset_provenance:
449
531
  suffixes.append("asset provenance production eligibility")
532
+ if args.require_asset_consistency:
533
+ suffixes.append("asset consistency contract")
534
+ if args.require_artifact_intake:
535
+ suffixes.append("runtime-ready artifact intake")
450
536
  suffix = f" + {' + '.join(suffixes)}" if suffixes else ""
451
537
  print(f"ACCEPTED {scene_dir.name}: context + spec + runtime snapshots + browser-review candidate + checklist{suffix}")
452
538
  return 1 if failed else 0
package/scripts/report.py CHANGED
@@ -63,6 +63,24 @@ def asset_provenance_module():
63
63
  return module
64
64
 
65
65
 
66
+ def asset_consistency_module():
67
+ path = ROOT / "scripts" / "asset-consistency.py"
68
+ loader = importlib.util.spec_from_file_location("motionloom_asset_consistency", path)
69
+ module = importlib.util.module_from_spec(loader)
70
+ sys.modules[loader.name] = module
71
+ loader.loader.exec_module(module)
72
+ return module
73
+
74
+
75
+ def artifact_intake_module():
76
+ path = ROOT / "scripts" / "artifact-intake.py"
77
+ loader = importlib.util.spec_from_file_location("motionloom_artifact_intake", path)
78
+ module = importlib.util.module_from_spec(loader)
79
+ sys.modules[loader.name] = module
80
+ loader.loader.exec_module(module)
81
+ return module
82
+
83
+
66
84
  def asset_provenance_result(scene_manifest_path: Path, scene_manifest: dict, mode: str = "runtime") -> dict:
67
85
  name = scene_manifest.get("asset_provenance")
68
86
  if not name:
@@ -81,6 +99,44 @@ def asset_provenance_result(scene_manifest_path: Path, scene_manifest: dict, mod
81
99
  return {"status": "fail", "errors": [f"asset provenance contract: {exc}"]}
82
100
 
83
101
 
102
+ def asset_consistency_result(scene_manifest_path: Path, scene_manifest: dict, strict: bool = False) -> dict:
103
+ name = scene_manifest.get("consistency_ref")
104
+ if not name:
105
+ return {"status": "not-run", "ready": False, "errors": []}
106
+ scene_dir = scene_manifest_path.parent.resolve()
107
+ consistency_path = (scene_dir / str(name)).resolve()
108
+ if scene_dir not in consistency_path.parents or not consistency_path.is_file():
109
+ return {"status": "fail", "ready": False, "errors": ["scene manifest consistency_ref points to a missing or unsafe artifact"]}
110
+ try:
111
+ document = read_json(consistency_path)
112
+ kind = scene_manifest.get("consistency_kind", "frame-geometry")
113
+ result = asset_consistency_module().run(kind, document, scene_dir, strict=strict)
114
+ result["status"] = "pass" if result.get("ready") else "fail"
115
+ return result
116
+ except (OSError, ValueError, AttributeError, KeyError, TypeError) as exc:
117
+ return {"status": "fail", "ready": False, "errors": [f"asset consistency contract: {exc}"]}
118
+
119
+
120
+ def artifact_intake_result(scene_manifest_path: Path, scene_manifest: dict, strict: bool = False) -> dict:
121
+ intake = scene_manifest.get("artifact_intake")
122
+ if not intake:
123
+ return {"status": "not-run", "ready": False, "errors": []}
124
+ required = ("registry", "receipt", "controls", "export_manifest")
125
+ scene_dir = scene_manifest_path.parent.resolve()
126
+ if not isinstance(intake, dict) or any(not isinstance(intake.get(key), str) for key in required):
127
+ return {"status": "fail", "ready": False, "errors": ["scene manifest artifact_intake is incomplete"]}
128
+ paths = {key: (scene_dir / intake[key]).resolve() for key in required}
129
+ if any(scene_dir not in path.parents or not path.is_file() for path in paths.values()):
130
+ return {"status": "fail", "ready": False, "errors": ["scene manifest artifact_intake points to a missing or unsafe artifact"]}
131
+ try:
132
+ result = artifact_intake_module().evaluate_bundle(
133
+ argparse.Namespace(root=scene_dir, registry=paths["registry"], receipt=paths["receipt"], controls=paths["controls"], export_manifest=paths["export_manifest"], strict=strict)
134
+ )
135
+ return result
136
+ except (OSError, ValueError, AttributeError, KeyError, TypeError) as exc:
137
+ return {"status": "fail", "ready": False, "errors": [f"artifact intake contract: {exc}"]}
138
+
139
+
84
140
  def memory_summary() -> dict | None:
85
141
  path = project_memory_path()
86
142
  if not path.is_file():
@@ -475,6 +531,18 @@ def check_report(args: argparse.Namespace) -> int:
475
531
  errors.append("ready-for-PR or confirmed task requires a passing asset provenance production check")
476
532
  elif not provenance.get("summary", {}).get("production_eligible"):
477
533
  errors.append("ready-for-PR or confirmed task requires asset provenance production_eligible")
534
+ consistency = asset_consistency_result(scene_manifest_path, scene_manifest, strict=state in {"ready_for_pr", "confirmed"})
535
+ if consistency.get("status") == "fail":
536
+ errors.extend(f"asset consistency: {error}" for error in consistency.get("errors", []))
537
+ if state in {"ready_for_pr", "confirmed"} and scene_manifest_path.is_file() and scene_manifest.get("consistency_ref"):
538
+ if consistency.get("status") != "pass" or not consistency.get("ready"):
539
+ errors.append("ready-for-PR or confirmed task requires a passing asset consistency contract")
540
+ intake = artifact_intake_result(scene_manifest_path, scene_manifest, strict=state in {"ready_for_pr", "confirmed"})
541
+ if intake.get("status") == "fail":
542
+ errors.extend(f"artifact intake: {error}" for error in intake.get("errors", []))
543
+ if state in {"ready_for_pr", "confirmed"} and scene_manifest_path.is_file() and scene_manifest.get("artifact_intake"):
544
+ if intake.get("status") != "pass" or not intake.get("ready"):
545
+ errors.append("ready-for-PR or confirmed task requires a runtime-ready artifact intake contract")
478
546
  if state in {"validated", "ready_for_pr", "confirmed"}:
479
547
  quality = read_json(task_dir / "quality-report.json")
480
548
  if quality.get("status") != "pass":
@@ -576,6 +644,18 @@ def render(args: argparse.Namespace) -> int:
576
644
  scene_manifest,
577
645
  "production" if task.get("state") in {"ready_for_pr", "confirmed"} else "runtime",
578
646
  )
647
+ consistency = asset_consistency_result(
648
+ ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json",
649
+ scene_manifest,
650
+ strict=task.get("state") in {"ready_for_pr", "confirmed"},
651
+ )
652
+ intake = artifact_intake_result(
653
+ ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json",
654
+ scene_manifest,
655
+ strict=task.get("state") in {"ready_for_pr", "confirmed"},
656
+ )
657
+ intake_adapter = intake.get("adapter") or {}
658
+ intake_evidence = intake.get("evidence") or {}
579
659
  lines = [
580
660
  f"# Animation Task Report — {task.get('task_id', task_dir.name)}",
581
661
  "",
@@ -609,6 +689,11 @@ def render(args: argparse.Namespace) -> int:
609
689
  "## Asset provenance",
610
690
  f"- Status: **{provenance.get('status', 'not-run')}**; authority: **{provenance.get('summary', {}).get('authority', 'unknown')}**; declared readiness: **{provenance.get('summary', {}).get('declared_readiness', 'blocked')}**; effective readiness: **{provenance.get('summary', {}).get('effective_readiness', 'blocked')}**",
611
691
  f"- Production eligible: **{provenance.get('summary', {}).get('production_eligible', False)}**; production approved: **{provenance.get('summary', {}).get('production_approved', False)}**; errors: **{len(provenance.get('errors', []))}**",
692
+ "## Asset consistency",
693
+ f"- Status: **{consistency.get('status', 'not-run')}**; contract: **{consistency.get('contract', scene_manifest.get('consistency_kind', 'not-declared'))}**; ready: **{consistency.get('ready', False)}**; errors: **{len(consistency.get('errors', []))}**; warnings: **{len(consistency.get('warnings', []))}**",
694
+ "## Artifact Intake",
695
+ f"- Status: **{intake.get('status', 'not-run')}**; adapter: **{intake_adapter.get('adapter_id', 'not-declared')}**; adapter status: **{intake_adapter.get('status', 'not-declared')}**; ready: **{intake.get('ready', False)}**; production eligible: **{intake.get('production_eligible', False)}**; production approved: **{intake.get('production_approved', False)}**",
696
+ f"- Receipt: `{intake_evidence.get('receipt', '')}`; controls: `{intake_evidence.get('controls', '')}`; export: `{intake_evidence.get('export_manifest', '')}`; errors: **{len(intake.get('errors', []))}**; warnings: **{len(intake.get('warnings', []))}**",
612
697
  "## Semantic motion lint",
613
698
  f"- Status: **{lint.get('status', 'not-run')}**; errors: **{lint.get('summary', {}).get('errors', 0)}**; warnings: **{lint.get('summary', {}).get('warnings', 0)}**; blocking: **{lint.get('summary', {}).get('blocking', 0)}**",
614
699
  md_table(lint.get("findings", []), [("Rule", "rule_id"), ("Severity", "severity"), ("Confidence", "confidence"), ("Message", "message"), ("Basis", "basis")]),