motionloom 2.2.0 → 2.4.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 (114) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +123 -17
  3. package/ROADMAP.md +8 -3
  4. package/SKILL.md +34 -3
  5. package/agent-card.json +48 -5
  6. package/agent-surfaces.json +8 -1
  7. package/artifact-adapter-registry.json +53 -0
  8. package/bin/motionloom.mjs +41 -4
  9. package/docs/AGENT-INTEGRATION.md +15 -2
  10. package/docs/CHECKLIST.md +21 -0
  11. package/docs/STATUS.md +2 -2
  12. package/docs/audits/data/deep-stress-latest.json +65 -65
  13. package/docs/releases/2.3.0.md +33 -0
  14. package/docs/releases/2.4.0.md +31 -0
  15. package/docs/releases/npm-publish-from-workstation.md +19 -8
  16. package/docs/research/agent-skill-ecosystem-notes.md +47 -0
  17. package/docs/research/ai-animation-tools-2026-notes.md +81 -0
  18. package/docs/research/ai-animation-tools-2026-report.md +168 -0
  19. package/docs/research/ai-pilot-ingest-notes.md +29 -0
  20. package/examples/agent-consumer/ai-generated-pilot/hero-male.json +10 -0
  21. package/examples/agent-consumer/ai-generated-pilot-provenance.json +55 -0
  22. package/examples/agent-consumer/ai-pilot-scout/CHATGPT-HANDOFF.md +70 -0
  23. package/examples/agent-consumer/ai-pilot-scout/CODEX-HANDOFF.md +80 -0
  24. package/examples/agent-consumer/ai-pilot-scout/README.md +39 -0
  25. package/examples/agent-consumer/ai-pilot-scout/chatgpt-geometry-correction-prompt.md +55 -0
  26. package/examples/agent-consumer/ai-pilot-scout/chatgpt-pose-generation-prompt.md +78 -0
  27. package/examples/agent-consumer/ai-pilot-scout/partial-handoff.json +73 -0
  28. package/examples/agent-consumer/artifact-intake/hero-motion-controls.json +15 -0
  29. package/examples/agent-consumer/artifact-intake/hero-motion-export.json +14 -0
  30. package/examples/agent-consumer/artifact-intake/hero-motion-provenance.json +20 -0
  31. package/examples/agent-consumer/artifact-intake/hero-motion-receipt.json +16 -0
  32. package/examples/agent-consumer/asset-consistency/assets/forest-back.png +0 -0
  33. package/examples/agent-consumer/asset-consistency/assets/forest-front.png +0 -0
  34. package/examples/agent-consumer/asset-consistency/assets/forest-mid.png +0 -0
  35. package/examples/agent-consumer/asset-consistency/assets/hero-atlas.png +0 -0
  36. package/examples/agent-consumer/asset-consistency/assets/hero-frame-00.png +0 -0
  37. package/examples/agent-consumer/asset-consistency/assets/hero-frame-01.png +0 -0
  38. package/examples/agent-consumer/asset-consistency/assets/hero-frame-02.png +0 -0
  39. package/examples/agent-consumer/asset-consistency/assets/hero-frame-03.png +0 -0
  40. package/examples/agent-consumer/asset-consistency/forest-layered-map.json +53 -0
  41. package/examples/agent-consumer/asset-consistency/hero-atlas-contract.json +24 -0
  42. package/examples/agent-consumer/asset-consistency/hero-identity.json +51 -0
  43. package/examples/agent-consumer/asset-consistency/hero-walk-action-set.json +27 -0
  44. package/examples/agent-consumer/asset-consistency/hero-walk-frame-geometry.json +63 -0
  45. package/examples/agent-consumer/rig-compatibility/hero-walk-fixture-rig.json +13 -0
  46. package/examples/agent-consumer/rive-package-gate/README.md +20 -0
  47. package/examples/agent-consumer/runtime-candidate/hero-walk-candidate.json +18 -0
  48. package/examples/agent-consumer/runtime-pilot/action-set.json +19 -0
  49. package/examples/agent-consumer/runtime-pilot/asset-identity.json +44 -0
  50. package/examples/agent-consumer/runtime-pilot/candidate.json +21 -0
  51. package/examples/agent-consumer/runtime-pilot/controls.json +35 -0
  52. package/examples/agent-consumer/runtime-pilot/export.json +17 -0
  53. package/examples/agent-consumer/runtime-pilot/provenance.json +38 -0
  54. package/examples/agent-consumer/runtime-pilot/receipt.json +30 -0
  55. package/package.json +65 -4
  56. package/references/agent-interoperability.md +11 -0
  57. package/references/browser-review-contract.md +7 -1
  58. package/references/intelligence-core.md +29 -2
  59. package/rig-adapter-registry.json +39 -0
  60. package/schemas/action-set.schema.json +42 -0
  61. package/schemas/agent-surfaces.schema.json +1 -1
  62. package/schemas/artifact-adapter-registry.schema.json +16 -0
  63. package/schemas/asset-identity.schema.json +117 -0
  64. package/schemas/asset-provenance.schema.json +183 -0
  65. package/schemas/atlas-contract.schema.json +48 -0
  66. package/schemas/control-track.schema.json +18 -0
  67. package/schemas/export-manifest.schema.json +19 -0
  68. package/schemas/frame-geometry.schema.json +79 -0
  69. package/schemas/generation-receipt.schema.json +20 -0
  70. package/schemas/layered-map.schema.json +75 -0
  71. package/schemas/rig-adapter-registry.schema.json +14 -0
  72. package/schemas/rig-compatibility.schema.json +53 -0
  73. package/schemas/rive-package-manifest.schema.json +43 -0
  74. package/schemas/runtime-candidate.schema.json +48 -0
  75. package/schemas/scene-manifest.schema.json +19 -0
  76. package/scripts/artifact-intake.py +408 -0
  77. package/scripts/asset-consistency.py +517 -0
  78. package/scripts/asset-provenance.py +395 -0
  79. package/scripts/build-ai-pilot.py +504 -0
  80. package/scripts/docs-audit.py +14 -2
  81. package/scripts/isolate-alpha-background.py +147 -0
  82. package/scripts/pr.py +2 -0
  83. package/scripts/quality-gate.py +127 -3
  84. package/scripts/report.py +135 -0
  85. package/scripts/resolve-task-bundle.py +84 -0
  86. package/scripts/review-hook.py +26 -4
  87. package/scripts/rig-compatibility.py +251 -0
  88. package/scripts/rive-package-gate.py +245 -0
  89. package/scripts/runtime-candidate.py +301 -0
  90. package/scripts/setup.mjs +500 -0
  91. package/scripts/skill-doctor.py +2 -1
  92. package/src/output/browser-review-smoke/asset-provenance.json +77 -0
  93. package/src/output/browser-review-smoke/manifest.json +1 -0
  94. package/src/output/browser-review-smoke/visual-truth.json +3 -3
  95. package/src/output/runtime-pilot-framer/asset-provenance.json +38 -0
  96. package/src/output/runtime-pilot-framer/browser-review.json +20 -0
  97. package/src/output/runtime-pilot-framer/framer-motion/runtime-telemetry.json +103 -0
  98. package/src/output/runtime-pilot-framer/manifest.json +31 -0
  99. package/src/output/runtime-pilot-framer/motion-spec.json +25 -0
  100. package/src/output/runtime-pilot-framer/runtime-evidence.json +96 -0
  101. package/src/output/runtime-pilot-framer/runtime-telemetry.json +100 -0
  102. package/src/output/runtime-pilot-framer/scene.jsx +52 -0
  103. package/src/output/runtime-pilot-framer/snapshot/.render-meta.json +9 -0
  104. package/src/output/runtime-pilot-framer/snapshot/frame-00.png +0 -0
  105. package/src/output/runtime-pilot-framer/snapshot/frame-100.png +0 -0
  106. package/src/output/runtime-pilot-framer/snapshot/frame-50.png +0 -0
  107. package/tests/scripts/run_tests.py +131 -0
  108. package/tests/scripts/test_ai_pilot_builder.py +128 -0
  109. package/tests/scripts/test_alpha_isolation.py +60 -0
  110. package/tests/scripts/test_artifact_intake.py +146 -0
  111. package/tests/scripts/test_asset_consistency.py +199 -0
  112. package/tests/scripts/test_rig_compatibility.py +105 -0
  113. package/tests/scripts/test_rive_package_gate.py +101 -0
  114. package/tests/scripts/test_runtime_candidate.py +95 -0
@@ -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())
package/scripts/pr.py CHANGED
@@ -95,6 +95,7 @@ def main() -> int:
95
95
  "--task-dir", str(task_dir),
96
96
  "--require-browser-review",
97
97
  "--require-visual-truth",
98
+ "--require-asset-provenance",
98
99
  ]
99
100
  print("== running context-bound quality gate ==")
100
101
  run(repo, [python, *quality_args])
@@ -121,6 +122,7 @@ def main() -> int:
121
122
  f"- snapshot frames: 0/50/100% in src/output/{args.scene}/snapshot/\n"
122
123
  "- context-bound quality gate: passed\n"
123
124
  f"- brand tokens bound from {args.context or 'project-context.json'}"
125
+ "\n- asset provenance: production_eligible (human approval remains separate)"
124
126
  )
125
127
  run(repo, ["git", "commit", "-m", commit_message])
126
128
 
@@ -55,6 +55,32 @@ def _load_visual_truth():
55
55
  return module
56
56
 
57
57
 
58
+ def _load_asset_provenance():
59
+ path = ROOT / "scripts" / "asset-provenance.py"
60
+ loader = importlib.util.spec_from_file_location("asset_provenance", path)
61
+ module = importlib.util.module_from_spec(loader)
62
+ loader.loader.exec_module(module)
63
+ return module
64
+
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
+
58
84
  def _json(path: Path):
59
85
  try:
60
86
  return json.loads(path.read_text(encoding="utf-8"))
@@ -74,7 +100,7 @@ def _telemetry_bundle_sha256(task_dir: Path) -> str:
74
100
  return hashlib.sha256(json.dumps(entries, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
75
101
 
76
102
 
77
- 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) -> 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]:
78
104
  issues = []
79
105
  manifest_path = scene_dir / "manifest.json"
80
106
  spec_path = scene_dir / "motion-spec.json"
@@ -108,6 +134,88 @@ def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = F
108
134
  if not (spec.get("accessibility") or {}).get("reduced_motion"):
109
135
  issues.append("motion spec has no reduced-motion policy")
110
136
 
137
+ asset_provenance_name = manifest.get("asset_provenance")
138
+ if require_asset_provenance and not isinstance(asset_provenance_name, str):
139
+ issues.append("asset provenance gate requires manifest.asset_provenance")
140
+ if asset_provenance_name or asset_provenance_path:
141
+ resolved_asset_provenance = asset_provenance_path or (scene_dir / str(asset_provenance_name)).resolve()
142
+ if scene_dir.resolve() not in resolved_asset_provenance.parents or not resolved_asset_provenance.is_file():
143
+ issues.append("manifest.asset_provenance must point to an existing file inside the scene directory")
144
+ else:
145
+ try:
146
+ asset_module = _load_asset_provenance()
147
+ asset_result = asset_module.evaluate(
148
+ resolved_asset_provenance,
149
+ base=scene_dir,
150
+ mode="production" if require_asset_provenance else "runtime",
151
+ manifest=manifest,
152
+ )
153
+ issues.extend(f"asset provenance: {issue}" for issue in asset_result.get("errors", []))
154
+ if require_asset_provenance and not asset_result.get("summary", {}).get("production_eligible"):
155
+ issues.append("asset provenance is not production_eligible; human approval remains separate")
156
+ except (OSError, ValueError, AttributeError) as exc:
157
+ issues.append(f"asset provenance contract: {exc}")
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
+
111
219
  checks = manifest.get("checks")
112
220
  if not isinstance(checks, list) or not checks:
113
221
  issues.append("manifest.checks must contain the Dev Lab quality checklist")
@@ -382,6 +490,11 @@ def main() -> int:
382
490
  parser.add_argument("--require-telemetry", action="store_true")
383
491
  parser.add_argument("--require-attestation", action="store_true")
384
492
  parser.add_argument("--require-visual-truth", action="store_true")
493
+ parser.add_argument("--require-asset-provenance", action="store_true")
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")
385
498
  parser.add_argument("--attestation")
386
499
  parser.add_argument("--trust-policy")
387
500
  args = parser.parse_args()
@@ -396,20 +509,31 @@ def main() -> int:
396
509
  task_dir = Path(args.task_dir).resolve() if args.task_dir else None
397
510
  attestation_path = Path(args.attestation).resolve() if args.attestation else None
398
511
  trust_policy_path = Path(args.trust_policy).resolve() if args.trust_policy else None
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
399
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 []
400
515
  if not scenes:
401
516
  print("QUALITY GATE: no scene outputs found")
402
517
  return 0
403
518
  failed = False
404
519
  for scene_dir in scenes:
405
- 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)
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)
406
521
  if issues:
407
522
  failed = True
408
523
  print(f"REJECTED {scene_dir.name}:")
409
524
  for issue in issues:
410
525
  print(f" - {issue}")
411
526
  else:
412
- suffix = " + visual-truth contract" if args.require_visual_truth else ""
527
+ suffixes = []
528
+ if args.require_visual_truth:
529
+ suffixes.append("visual-truth contract")
530
+ if args.require_asset_provenance:
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")
536
+ suffix = f" + {' + '.join(suffixes)}" if suffixes else ""
413
537
  print(f"ACCEPTED {scene_dir.name}: context + spec + runtime snapshots + browser-review candidate + checklist{suffix}")
414
538
  return 1 if failed else 0
415
539
 
package/scripts/report.py CHANGED
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  import hashlib
8
+ import importlib.util
8
9
  import json
9
10
  import shutil
10
11
  import sys
@@ -54,6 +55,88 @@ def project_memory_path() -> Path:
54
55
  return ROOT / ".motionloom" / "project-memory.json"
55
56
 
56
57
 
58
+ def asset_provenance_module():
59
+ path = ROOT / "scripts" / "asset-provenance.py"
60
+ loader = importlib.util.spec_from_file_location("motionloom_asset_provenance", path)
61
+ module = importlib.util.module_from_spec(loader)
62
+ loader.loader.exec_module(module)
63
+ return module
64
+
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
+
84
+ def asset_provenance_result(scene_manifest_path: Path, scene_manifest: dict, mode: str = "runtime") -> dict:
85
+ name = scene_manifest.get("asset_provenance")
86
+ if not name:
87
+ return {"status": "not-run", "errors": ["scene manifest has no asset_provenance"]}
88
+ provenance_path = (scene_manifest_path.parent / str(name)).resolve()
89
+ if scene_manifest_path.parent.resolve() not in provenance_path.parents or not provenance_path.is_file():
90
+ return {"status": "fail", "errors": ["scene manifest asset_provenance points to a missing or unsafe artifact"]}
91
+ try:
92
+ return asset_provenance_module().evaluate(
93
+ provenance_path,
94
+ base=scene_manifest_path.parent,
95
+ mode=mode,
96
+ manifest=scene_manifest,
97
+ )
98
+ except (OSError, ValueError, AttributeError) as exc:
99
+ return {"status": "fail", "errors": [f"asset provenance contract: {exc}"]}
100
+
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
+
57
140
  def memory_summary() -> dict | None:
58
141
  path = project_memory_path()
59
142
  if not path.is_file():
@@ -433,6 +516,33 @@ def check_report(args: argparse.Namespace) -> int:
433
516
  visual_truth_path = scene_manifest_path.parent / str(visual_truth_name)
434
517
  if not visual_truth_path.is_file():
435
518
  errors.append("scene manifest visual_truth points to a missing artifact")
519
+ provenance_mode = "production" if state in {"ready_for_pr", "confirmed"} else "runtime"
520
+ provenance = asset_provenance_result(scene_manifest_path, scene_manifest, provenance_mode)
521
+ if provenance.get("status") == "fail":
522
+ errors.extend(f"asset provenance: {error}" for error in provenance.get("errors", []))
523
+ # Legacy report-contract fixtures may exercise lifecycle/report behavior
524
+ # without materializing a scene manifest. Do not invent provenance for
525
+ # those synthetic tasks. Once a real scene manifest exists, readiness is
526
+ # fail-closed and its asset_provenance reference is mandatory for PR
527
+ # states; the production quality gate remains independently strict when
528
+ # --require-asset-provenance is supplied.
529
+ if state in {"ready_for_pr", "confirmed"} and scene_manifest_path.is_file():
530
+ if provenance.get("status") != "pass":
531
+ errors.append("ready-for-PR or confirmed task requires a passing asset provenance production check")
532
+ elif not provenance.get("summary", {}).get("production_eligible"):
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")
436
546
  if state in {"validated", "ready_for_pr", "confirmed"}:
437
547
  quality = read_json(task_dir / "quality-report.json")
438
548
  if quality.get("status") != "pass":
@@ -529,6 +639,23 @@ def render(args: argparse.Namespace) -> int:
529
639
  ROOT / "src" / "output" / str(task.get("scene", "")) / str(scene_manifest.get("visual_truth", "")),
530
640
  {},
531
641
  ) if scene_manifest.get("visual_truth") else {}
642
+ provenance = asset_provenance_result(
643
+ ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json",
644
+ scene_manifest,
645
+ "production" if task.get("state") in {"ready_for_pr", "confirmed"} else "runtime",
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 {}
532
659
  lines = [
533
660
  f"# Animation Task Report — {task.get('task_id', task_dir.name)}",
534
661
  "",
@@ -559,6 +686,14 @@ def render(args: argparse.Namespace) -> int:
559
686
  f"- Status: **{visual_truth.get('status', 'not-run')}**; scene: `{visual_truth.get('scene', task.get('scene', ''))}`; approval: **{visual_truth.get('review_boundary', {}).get('approval', False)}**",
560
687
  f"- Baseline: `{visual_truth.get('frames', {}).get('baseline', {}).get('path', '')}`; candidate: `{visual_truth.get('frames', {}).get('candidate', {}).get('path', '')}`",
561
688
  f"- Changed pixels: **{visual_truth.get('comparison', {}).get('changed_pixels', 'not-run')}**; changed regions: **{len(visual_truth.get('comparison', {}).get('regions', []))}**",
689
+ "## Asset provenance",
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')}**",
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', []))}**",
562
697
  "## Semantic motion lint",
563
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)}**",
564
699
  md_table(lint.get("findings", []), [("Rule", "rule_id"), ("Severity", "severity"), ("Confidence", "confidence"), ("Message", "message"), ("Basis", "basis")]),
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve one safe artifact task bundle bound to a scene identity.
3
+
4
+ The resolver intentionally does not infer a task path from a scene slug. A
5
+ bundle is eligible only when its direct ``artifacts/<task-id>/task.json``
6
+ declares the requested scene and remains inside the repository artifacts root.
7
+ Multiple matching bundles are rejected rather than ranked implicitly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import re
15
+ import sys
16
+ from pathlib import Path
17
+
18
+
19
+ SAFE_SCENE = re.compile(r"^[A-Za-z0-9._-]+$")
20
+
21
+
22
+ def read_json(path: Path) -> dict:
23
+ try:
24
+ data = json.loads(path.read_text(encoding="utf-8"))
25
+ except (OSError, json.JSONDecodeError):
26
+ return {}
27
+ return data if isinstance(data, dict) else {}
28
+
29
+
30
+ def has_symlink_component(path: Path, root: Path) -> bool:
31
+ current = path
32
+ while True:
33
+ if current.is_symlink():
34
+ return True
35
+ if current == root:
36
+ return False
37
+ if current == current.parent:
38
+ return True
39
+ current = current.parent
40
+
41
+
42
+ def resolve_task_dirs(root: Path, scene: str) -> list[Path]:
43
+ artifacts = root / "artifacts"
44
+ if not artifacts.is_dir() or artifacts.is_symlink():
45
+ return []
46
+ resolved_artifacts = artifacts.resolve()
47
+ matches: list[Path] = []
48
+ for task_path in sorted(artifacts.glob("*/task.json")):
49
+ if has_symlink_component(task_path, root):
50
+ continue
51
+ try:
52
+ if not task_path.resolve().is_relative_to(resolved_artifacts):
53
+ continue
54
+ except (OSError, RuntimeError):
55
+ continue
56
+ if read_json(task_path).get("scene") == scene:
57
+ matches.append(task_path.parent)
58
+ return matches
59
+
60
+
61
+ def main() -> int:
62
+ parser = argparse.ArgumentParser(description=__doc__)
63
+ parser.add_argument("--scene", required=True)
64
+ parser.add_argument("--root", default=".")
65
+ args = parser.parse_args()
66
+ if not SAFE_SCENE.fullmatch(args.scene) or args.scene in {".", ".."}:
67
+ print("FAIL: unsafe scene identifier", file=sys.stderr)
68
+ return 2
69
+
70
+ root = Path(args.root).resolve()
71
+ matches = resolve_task_dirs(root, args.scene)
72
+ if not matches:
73
+ print(f"FAIL: no safe task bundle bound to scene: {args.scene}", file=sys.stderr)
74
+ return 3
75
+ if len(matches) != 1:
76
+ joined = ", ".join(str(path.relative_to(root)) for path in matches)
77
+ print(f"FAIL: ambiguous task bundles bound to scene {args.scene}: {joined}", file=sys.stderr)
78
+ return 4
79
+ print(matches[0].relative_to(root).as_posix())
80
+ return 0
81
+
82
+
83
+ if __name__ == "__main__":
84
+ raise SystemExit(main())
@@ -12,7 +12,7 @@ import subprocess
12
12
  import sys
13
13
  from datetime import datetime, timedelta, timezone
14
14
  from pathlib import Path
15
- from urllib.parse import urlencode
15
+ from urllib.parse import urlencode, urlsplit, urlunsplit
16
16
 
17
17
  ROOT = Path(__file__).resolve().parents[1]
18
18
  SAFE_SCENE = re.compile(r"^[A-Za-z0-9._-]+$")
@@ -42,6 +42,29 @@ def candidate_id(task_id: str, scene: str, context_hash: str, source_hash: str,
42
42
  return hashlib.sha256(raw).hexdigest()[:20]
43
43
 
44
44
 
45
+ def review_urls(lab_url: str, scene: str, task_id: str, candidate: str) -> tuple[str, str, str]:
46
+ """Build an exact review route while anchoring artifact paths at its origin.
47
+
48
+ `--lab-url` is the actual browser route, e.g. `https://host/lab` for the
49
+ React Dev Lab or `http://127.0.0.1:3300/` for the bundled static lab.
50
+ Artifact and task paths intentionally remain same-origin root paths.
51
+ """
52
+ parsed = urlsplit(lab_url.strip())
53
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.query or parsed.fragment:
54
+ raise ValueError("lab-url must be an absolute http(s) Dev Lab route without query or fragment")
55
+ origin = urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
56
+ review_path = parsed.path.rstrip("/") or "/"
57
+ review_base = f"{origin}{review_path}" if review_path != "/" else origin
58
+ query = urlencode({
59
+ "scene": scene,
60
+ "task_id": task_id,
61
+ "candidate_id": candidate,
62
+ "artifact_base": f"{origin}/scenes/{scene}",
63
+ "task_base": f"{origin}/tasks/{task_id}",
64
+ })
65
+ return f"{review_base}/?{query}" if review_path != "/" else f"{origin}/?{query}", f"{origin}/scenes/{scene}", f"{origin}/tasks/{task_id}"
66
+
67
+
45
68
  def paths(task_dir: Path, task: dict) -> tuple[Path, Path, Path, Path]:
46
69
  scene = task.get("scene", "")
47
70
  if not scene or scene in {".", ".."} or not SAFE_SCENE.fullmatch(scene):
@@ -79,9 +102,8 @@ def prepare(args: argparse.Namespace) -> int:
79
102
  source_hash = sha256(source_path)
80
103
  render_hash = sha256(render_meta)
81
104
  cid = candidate_id(task["task_id"], scene, context_hash, source_hash, render_hash)
82
- base = args.lab_url.rstrip("/")
83
105
  task_id = task["task_id"]
84
- url = f"{base}/?{urlencode({'scene': scene, 'task_id': task_id, 'candidate_id': cid, 'artifact_base': f'{base}/scenes/{scene}', 'task_base': f'{base}/tasks/{task_id}'})}"
106
+ url, artifact_base, task_base = review_urls(args.lab_url, scene, task_id, cid)
85
107
  candidate = {
86
108
  "schema_version": "1.0",
87
109
  "candidate_id": cid,
@@ -120,7 +142,7 @@ def prepare(args: argparse.Namespace) -> int:
120
142
  handoff = read_json(handoff_path)
121
143
  if memory_path.is_file():
122
144
  shutil.copy2(memory_path, task_dir / "project-memory.json")
123
- handoff.update({"state": "review_required", "to_agent": "browser-review-agent", "summary": "Open the exact rendered candidate in the internal Dev Lab and obtain user approval before PR.", "next_actions": [{"action": "Open internal Dev Lab candidate", "kind": "browser_review", "agent": "browser-review-agent", "skill": "browser-review", "url": url, "candidate_id": cid, "requires_user_approval": True, "evidence_needed": ["review.json"], "output_artifacts": ["review.json"]}], "required_artifacts": sorted(set(handoff.get("required_artifacts", []) + ["browser-review.json", "review.json", "semantic-lint-benchmark.json", "evidence-verifier-report.json", "runtime-adapters/runtime-evidence.json"] + (["project-memory.json"] if memory_path.is_file() else [])))})
145
+ handoff.update({"state": "review_required", "to_agent": "browser-review-agent", "summary": "Open the exact rendered candidate in the internal Dev Lab and obtain user approval before PR.", "next_actions": [{"action": "Open internal Dev Lab candidate", "kind": "browser_review", "agent": "browser-review-agent", "skill": "browser-review", "url": url, "artifact_base": artifact_base, "task_base": task_base, "candidate_id": cid, "requires_user_approval": True, "evidence_needed": ["review.json"], "output_artifacts": ["review.json"]}], "required_artifacts": sorted(set(handoff.get("required_artifacts", []) + ["browser-review.json", "review.json", "semantic-lint-benchmark.json", "evidence-verifier-report.json", "runtime-adapters/runtime-evidence.json"] + (["project-memory.json"] if memory_path.is_file() else [])))})
124
146
  handoff_path.write_text(json.dumps(handoff, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
125
147
  subprocess.run([sys.executable, str(ROOT / "scripts/devlab.py"), scene, "--prepare-only", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
126
148
  subprocess.run([sys.executable, str(ROOT / "scripts/report.py"), "collect", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)