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
@@ -0,0 +1,504 @@
1
+ #!/usr/bin/env python3
2
+ """Build a hash-bound, review-only MotionLoom AI scout pilot from four PNG frames.
3
+
4
+ The builder is intentionally pilot-specific. It copies source bytes into a local
5
+ ``.motionloom`` workspace, measures their actual alpha geometry using the same
6
+ deterministic reader as Asset Consistency, and writes the contracts that feed
7
+ Artifact Intake and Runtime Candidate. It never manufactures a human approval,
8
+ artist authority, or runtime-pass claim.
9
+
10
+ It uses Python's standard library only and runs on Ubuntu, macOS, and Windows.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import hashlib
17
+ import importlib.util
18
+ import json
19
+ import shutil
20
+ import sys
21
+ from datetime import UTC, datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+
26
+ REPO_ROOT = Path(__file__).resolve().parents[1]
27
+ ASSET_ID = "character.scout-robot.ai-pilot"
28
+ PILOT_ID = "ai-pilot-scout-walk"
29
+ FRAME_ORDER = (
30
+ ("idle", "idle", "preview"),
31
+ ("contact-right", "contact_right", "runtime_candidate"),
32
+ ("passing", "passing", "runtime_candidate"),
33
+ ("contact-left", "contact_left", "runtime_candidate"),
34
+ )
35
+ PROVIDER_PROFILES = {
36
+ "internal-imagegen": {
37
+ "adapter_id": "internal.imagegen",
38
+ "kind": "internal_skill",
39
+ "invocation_mode": "agent-mediated",
40
+ "cost_class": "included",
41
+ "generator_source": "internal-imagegen",
42
+ "default_model": "default",
43
+ "default_task_id": "motionloom-scout-ai-pilot-ingest-2026",
44
+ "license_source": "Internal ImageGen generation; provider-native receipt was not exported",
45
+ "reconstruction_source": "agent-mediated ImageGen source",
46
+ "lab_limit": "Internal ImageGen adapter is scaffold-only",
47
+ },
48
+ "chatgpt": {
49
+ "adapter_id": "openai.chatgpt",
50
+ "kind": "external_provider",
51
+ "invocation_mode": "manual",
52
+ "cost_class": "external",
53
+ "generator_source": "openai-chatgpt",
54
+ "default_model": "chatgpt-image-generation",
55
+ "default_task_id": "motionloom-scout-chatgpt-pilot-2026",
56
+ "license_source": "User-provided ChatGPT generation; provider-native receipt was not exported",
57
+ "reconstruction_source": "user-mediated ChatGPT source",
58
+ "lab_limit": "ChatGPT import adapter is scaffold-only",
59
+ },
60
+ }
61
+
62
+
63
+ def sha256_file(path: Path) -> str:
64
+ return hashlib.sha256(path.read_bytes()).hexdigest()
65
+
66
+
67
+ def sha256_text(value: str) -> str:
68
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
69
+
70
+
71
+ def write_json(path: Path, value: dict[str, Any]) -> None:
72
+ path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
73
+
74
+
75
+ def load_png_reader() -> Any:
76
+ source = REPO_ROOT / "scripts" / "asset-consistency.py"
77
+ spec = importlib.util.spec_from_file_location("motionloom_pilot_png", source)
78
+ if spec is None or spec.loader is None:
79
+ raise RuntimeError(f"could not load deterministic PNG reader: {source}")
80
+ module = importlib.util.module_from_spec(spec)
81
+ sys.modules[spec.name] = module
82
+ spec.loader.exec_module(module)
83
+ return module.PNGImage
84
+
85
+
86
+ def parse_urls(values: list[str]) -> dict[str, str]:
87
+ result: dict[str, str] = {}
88
+ for value in values:
89
+ frame_id, separator, url = value.partition("=")
90
+ if not separator or frame_id not in {item[0] for item in FRAME_ORDER} or not url.startswith(("/", "https://", "http://")):
91
+ raise ValueError("--frame-url must look like idle=/manus-storage/frame.png")
92
+ result[frame_id] = url
93
+ return result
94
+
95
+
96
+ def measured_frame(PNGImage: Any, path: Path) -> dict[str, Any]:
97
+ image = PNGImage(path)
98
+ supports_alpha = image.color_type in {4, 6} or (image.color_type == 3 and bool(image._transparency))
99
+ if not supports_alpha:
100
+ raise ValueError(
101
+ f"{path.name} is color type {image.color_type} and has no alpha channel; "
102
+ "do not treat a painted checkerboard as transparency"
103
+ )
104
+ total = image.width * image.height
105
+ alpha_pixels = image.alpha_count(1)
106
+ if alpha_pixels == total:
107
+ raise ValueError(f"{path.name} has no transparent padding; an isolated alpha frame is required")
108
+ bbox = image.alpha_bbox(1)
109
+ if bbox is None:
110
+ raise ValueError(f"{path.name} has no visible alpha pixels")
111
+ return {
112
+ "width": image.width,
113
+ "height": image.height,
114
+ "alpha_pixels": alpha_pixels,
115
+ "alpha_bbox": bbox,
116
+ "sha256": sha256_file(path),
117
+ "bytes": path.stat().st_size,
118
+ }
119
+
120
+
121
+ def require_clean_pilot_geometry(frame_id: str, data: dict[str, Any]) -> None:
122
+ """Reject canvas-spanning residue for this isolated, single-character pilot.
123
+
124
+ Alpha isolation can deliberately remove only edge-connected background. A
125
+ detached band may therefore remain alpha-opaque without touching an edge.
126
+ The pilot has a narrow, centred single-character contract, so its measured
127
+ alpha bounds must retain padding on every side and cannot occupy most of a
128
+ canvas width. This is a geometry guard, not a human-quality approval.
129
+ """
130
+ bbox = data["alpha_bbox"]
131
+ width, height = int(data["width"]), int(data["height"])
132
+ minimum_padding = max(1, round(min(width, height) * 0.05))
133
+ paddings = {
134
+ "left": int(bbox["x"]),
135
+ "top": int(bbox["y"]),
136
+ "right": width - (int(bbox["x"]) + int(bbox["width"])),
137
+ "bottom": height - (int(bbox["y"]) + int(bbox["height"])),
138
+ }
139
+ if min(paddings.values()) < minimum_padding:
140
+ raise ValueError(
141
+ f"{frame_id} does not retain {minimum_padding}px clean padding on every edge; "
142
+ f"measured {paddings}. Reject possible canvas-spanning contamination."
143
+ )
144
+ if int(bbox["width"]) / width > 0.6:
145
+ raise ValueError(
146
+ f"{frame_id} alpha subject spans {int(bbox['width'])}/{width}px of the canvas width; "
147
+ "reject possible detached horizontal contamination."
148
+ )
149
+
150
+
151
+ def partial_frame_record(frame_id: str, source: Path, data: dict[str, Any] | None, error: str | None) -> dict[str, Any]:
152
+ """Describe a rejected source without emitting an ingestable candidate."""
153
+ record: dict[str, Any] = {
154
+ "role": frame_id,
155
+ "source": str(source),
156
+ "sha256": sha256_file(source),
157
+ "bytes": source.stat().st_size,
158
+ }
159
+ if data is not None:
160
+ bbox = data["alpha_bbox"]
161
+ record["canvas"] = [data["width"], data["height"]]
162
+ record["alpha_bbox"] = [bbox["x"], bbox["y"], bbox["width"], bbox["height"]]
163
+ record["padding"] = {
164
+ "left": bbox["x"],
165
+ "top": bbox["y"],
166
+ "right": data["width"] - (bbox["x"] + bbox["width"]),
167
+ "bottom": data["height"] - (bbox["y"] + bbox["height"]),
168
+ }
169
+ if error is not None:
170
+ record["error"] = error
171
+ return record
172
+
173
+
174
+ def write_partial_handoff(
175
+ output: Path,
176
+ provider: dict[str, str],
177
+ provider_model: str,
178
+ provider_task_id: str,
179
+ timestamp: str,
180
+ rejected_frames: list[dict[str, Any]],
181
+ errors: list[str],
182
+ ) -> None:
183
+ """Write source-bound refusal evidence; never create controls or a candidate."""
184
+ handoff = {
185
+ "contract": "motionloom-ai-pilot-partial-handoff",
186
+ "version": 1,
187
+ "asset_id": ASSET_ID,
188
+ "state": "partial",
189
+ "authority": "ai_generated",
190
+ "production_approved": False,
191
+ "created_at": timestamp,
192
+ "blocked": {
193
+ "reason": "Source preflight failed. No canvas resampling, transparent padding, metadata edit, intake, candidate, render, or approval was emitted.",
194
+ "errors": errors,
195
+ "missing_frames": [entry["role"] for entry in rejected_frames if entry.get("error")],
196
+ "not_emitted": [
197
+ "controls.json",
198
+ "generation receipt",
199
+ "export manifest",
200
+ "asset consistency contract",
201
+ "Artifact Intake report",
202
+ "runtime candidate",
203
+ "runtime render evidence",
204
+ "Dev Lab review candidate",
205
+ ],
206
+ },
207
+ "rejected_source_attempts": [
208
+ {
209
+ "provider": provider["generator_source"],
210
+ "adapter_id": provider["adapter_id"],
211
+ "model": provider_model,
212
+ "task_id": provider_task_id,
213
+ "authority": "ai_generated",
214
+ "state": "rejected_pre_ingest",
215
+ "reason": "Measured source bytes failed the pilot preflight; no transform may bypass the gate.",
216
+ "frames": rejected_frames,
217
+ }
218
+ ],
219
+ "resume_requirements": [
220
+ "Provide independent source frames that satisfy the measured canvas and clean-padding requirements.",
221
+ "Do not resize, pad, crop, isolate, or relabel rejected bytes to bypass the source gate.",
222
+ "Re-run build-ai-pilot and downstream pipeline stages only after preflight succeeds.",
223
+ ],
224
+ }
225
+ write_json(output / "partial-handoff.json", handoff)
226
+
227
+
228
+ def frame_entry(frame_id: str, role: str, target: str, file_name: str, data: dict[str, Any], root_relative_output: str) -> dict[str, Any]:
229
+ return {
230
+ "path": f"{root_relative_output}/frames/{file_name}",
231
+ "role": f"walk_{role}",
232
+ "sha256": data["sha256"],
233
+ "bytes": data["bytes"],
234
+ "target": target,
235
+ }
236
+
237
+
238
+ def main(argv: list[str] | None = None) -> int:
239
+ parser = argparse.ArgumentParser(description="Build MotionLoom's review-only scout AI pilot workspace")
240
+ parser.add_argument("--idle", required=True, type=Path, help="Alpha-isolated idle PNG")
241
+ parser.add_argument("--contact-right", required=True, type=Path, help="Alpha-isolated contact-right PNG")
242
+ parser.add_argument("--passing", required=True, type=Path, help="Alpha-isolated passing PNG")
243
+ parser.add_argument("--contact-left", required=True, type=Path, help="Alpha-isolated contact-left PNG")
244
+ parser.add_argument("--root", type=Path, default=REPO_ROOT, help="MotionLoom repository root")
245
+ parser.add_argument(
246
+ "--output",
247
+ default=".motionloom/pilots/ai-pilot-scout",
248
+ help="Safe relative output directory inside --root",
249
+ )
250
+ parser.add_argument("--frame-url", action="append", default=[], help="Optional frame URL: idle=/manus-storage/frame.png")
251
+ parser.add_argument("--generated-at", default=None, help="ISO-8601 evidence timestamp; defaults to build time")
252
+ parser.add_argument("--provider", choices=sorted(PROVIDER_PROFILES), default="internal-imagegen", help="Truthful source provider profile")
253
+ parser.add_argument("--provider-model", default=None, help="Provider/model label for provenance; defaults to profile value")
254
+ parser.add_argument("--provider-task-id", default=None, help="Provider or user-mediated task identifier for provenance")
255
+ parser.add_argument("--overwrite", action="store_true", help="Replace an existing pilot workspace")
256
+ args = parser.parse_args(argv)
257
+
258
+ root = args.root.resolve()
259
+ output = (root / args.output).resolve()
260
+ try:
261
+ output.relative_to(root)
262
+ except ValueError as exc:
263
+ raise SystemExit("--output must stay inside --root") from exc
264
+ if output.exists():
265
+ if not args.overwrite:
266
+ raise SystemExit(f"pilot workspace already exists: {output}; pass --overwrite to replace it")
267
+ shutil.rmtree(output)
268
+ urls = parse_urls(args.frame_url)
269
+ timestamp = args.generated_at or datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
270
+ provider = PROVIDER_PROFILES[args.provider]
271
+ provider_model = args.provider_model or provider["default_model"]
272
+ provider_task_id = args.provider_task_id or provider["default_task_id"]
273
+
274
+ sources = {
275
+ "idle": args.idle,
276
+ "contact-right": args.contact_right,
277
+ "passing": args.passing,
278
+ "contact-left": args.contact_left,
279
+ }
280
+ for frame_id, source in sources.items():
281
+ if not source.is_file():
282
+ raise SystemExit(f"missing {frame_id} source image: {source}")
283
+
284
+ output.mkdir(parents=True)
285
+ frames_dir = output / "frames"
286
+ frames_dir.mkdir()
287
+ PNGImage = load_png_reader()
288
+ frames: dict[str, dict[str, Any]] = {}
289
+ rejected_frames: list[dict[str, Any]] = []
290
+ preflight_errors: list[str] = []
291
+ for frame_id, role, _ in FRAME_ORDER:
292
+ destination = frames_dir / f"scout-{frame_id}.png"
293
+ shutil.copy2(sources[frame_id], destination)
294
+ data: dict[str, Any] | None = None
295
+ try:
296
+ data = measured_frame(PNGImage, destination)
297
+ require_clean_pilot_geometry(frame_id, data)
298
+ except ValueError as exc:
299
+ error = str(exc)
300
+ preflight_errors.append(error)
301
+ rejected_frames.append(partial_frame_record(frame_id, destination, data, error))
302
+ continue
303
+ assert data is not None
304
+ frames[frame_id] = data
305
+ rejected_frames.append(partial_frame_record(frame_id, destination, data, None))
306
+
307
+ if not preflight_errors:
308
+ canvas_sizes = {(item["width"], item["height"]) for item in frames.values()}
309
+ if len(canvas_sizes) != 1:
310
+ preflight_errors.append(f"all frames must use one canvas; measured {sorted(canvas_sizes)}")
311
+
312
+ if preflight_errors:
313
+ write_partial_handoff(output, provider, provider_model, provider_task_id, timestamp, rejected_frames, preflight_errors)
314
+ print(json.dumps({"status": "partial", "root": str(root), "output": output.relative_to(root).as_posix(), "asset_id": ASSET_ID, "errors": preflight_errors}, indent=2))
315
+ return 2
316
+
317
+ canvas_sizes = {(item["width"], item["height"]) for item in frames.values()}
318
+ width, height = next(iter(canvas_sizes))
319
+ alpha_bboxes = [item["alpha_bbox"] for item in frames.values()]
320
+ footlines = [bbox["y"] + bbox["height"] - 1 for bbox in alpha_bboxes]
321
+ pivots = [(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] - 1) for bbox in alpha_bboxes]
322
+ reference_pivot = pivots[0]
323
+ pivot_tolerance = max(max(abs(x - reference_pivot[0]), abs(y - reference_pivot[1])) for x, y in pivots)
324
+ footline_tolerance = max(abs(value - footlines[0]) for value in footlines)
325
+ bbox_tolerance = max(
326
+ max(abs(item["width"] - alpha_bboxes[0]["width"]), abs(item["height"] - alpha_bboxes[0]["height"]))
327
+ for item in alpha_bboxes
328
+ )
329
+ min_alpha = min(int(item["alpha_pixels"]) for item in frames.values())
330
+ idle_hash = frames["idle"]["sha256"]
331
+ prompt_capsule = (
332
+ f"MotionLoom scout robot AI pilot imported from {provider['generator_source']}. "
333
+ "Post-hoc ingest capsule only; provider-native prompt and task receipt were not exported. "
334
+ "The capsule binds reviewed output bytes without asserting a human approval or artist authorship."
335
+ )
336
+ prompt_hash = sha256_text(prompt_capsule)
337
+ motion_hash = sha256_text("walk_pilot_4frame:idle,contact-right,passing,contact-left")
338
+ style_hash = sha256_text("cream-orange-black-cyan pixel-art scout robot style lock")
339
+
340
+ relative_output = output.relative_to(root).as_posix()
341
+ geometry_frames: list[dict[str, Any]] = []
342
+ receipt_outputs: list[dict[str, Any]] = []
343
+ export_outputs: list[dict[str, Any]] = []
344
+ provenance_files: list[dict[str, Any]] = []
345
+ for index, (frame_id, role, target) in enumerate(FRAME_ORDER):
346
+ data = frames[frame_id]
347
+ bbox = data["alpha_bbox"]
348
+ pivot = pivots[index]
349
+ filename = f"scout-{frame_id}.png"
350
+ geometry_frames.append(
351
+ {
352
+ "frame_id": f"scout-{frame_id}",
353
+ "image": f"frames/{filename}",
354
+ "rect": {"x": 0, "y": 0, "width": width, "height": height},
355
+ "alpha_bbox": bbox,
356
+ "pivot": {"x": pivot[0], "y": pivot[1], "space": "pixels"},
357
+ "footline_px": footlines[index],
358
+ "safe_rect": bbox,
359
+ "bleed_margin_px": 0,
360
+ "sha256": data["sha256"],
361
+ }
362
+ )
363
+ record = frame_entry(frame_id, role, target, filename, data, relative_output)
364
+ receipt_outputs.append({key: value for key, value in record.items() if key != "target"})
365
+ export_outputs.append(record)
366
+ provenance_files.append(
367
+ {
368
+ "path": record["path"],
369
+ "role": record["role"],
370
+ "sha256": record["sha256"],
371
+ "bytes": record["bytes"],
372
+ }
373
+ )
374
+
375
+ controls = {
376
+ "schema_version": "0.1",
377
+ "control_id": "scout-ai-pilot-controls-v1",
378
+ "asset_id": ASSET_ID,
379
+ "created_at": timestamp,
380
+ "references": [{"id": "scout-idle-master", "path": f"{relative_output}/frames/scout-idle.png", "sha256": idle_hash, "role": "identity"}],
381
+ "tracks": [
382
+ {"id": "identity-lock", "kind": "identity", "binding": "required", "value_hash": idle_hash, "samples": 1},
383
+ {"id": "style-lock", "kind": "style", "binding": "advisory", "value_hash": style_hash, "samples": 4},
384
+ {"id": "pose-motion", "kind": "pose", "binding": "required", "value_hash": motion_hash, "samples": 4},
385
+ ],
386
+ "output_profile": {"kind": "frame_sequence", "fps": 8, "expected_frame_count": 4, "loop": True},
387
+ }
388
+ provenance = {
389
+ "schema_version": "1.0",
390
+ "provenance_id": "scout-ai-pilot-provenance-v1",
391
+ "task_id": provider_task_id,
392
+ "scene": "scout-walk",
393
+ "created_at": timestamp,
394
+ "asset": {"id": ASSET_ID, "path": f"{relative_output}/frames/scout-idle.png", "type": "frame_sequence", "framework": "canvas", "version": "pilot-v1"},
395
+ "authority": "ai_generated",
396
+ "readiness": "runtime_ready",
397
+ "generator": {"model": provider_model, "task_id": provider_task_id, "source": provider["generator_source"], "generated_at": timestamp, "prompt_hash": prompt_hash, "agent": "MotionLoom"},
398
+ "license": {"spdx": "LicenseRef-Generated-Pilot", "source": provider["license_source"], "attribution": "AI-generated scout pilot; human review remains required"},
399
+ "files": provenance_files,
400
+ "provenance_chain": [{"step": "ingest", "actor": "MotionLoom", "source": f"post-hoc hash-bound local ingest from {provider['reconstruction_source']}; not a provider-native receipt", "timestamp": timestamp}],
401
+ "runtime_evidence": {"status": "not_run", "runtime": "canvas", "tested_at": timestamp},
402
+ }
403
+ receipt = {
404
+ "schema_version": "0.1",
405
+ "receipt_id": "scout-ai-pilot-receipt-v1",
406
+ "created_at": timestamp,
407
+ "asset": {"id": ASSET_ID, "kind": "frame_sequence", "intended_use": "runtime_candidate"},
408
+ "authority": "ai_generated",
409
+ "provider": {"adapter_id": provider["adapter_id"], "kind": provider["kind"], "invocation_mode": provider["invocation_mode"], "task_id": provider_task_id, "model": provider_model, "generated_at": timestamp, "cost_class": provider["cost_class"], "prompt_hash": prompt_hash},
410
+ "control_track_ref": f"{relative_output}/controls.json",
411
+ "provenance_ref": f"{relative_output}/provenance.json",
412
+ "outputs": receipt_outputs,
413
+ }
414
+ export = {
415
+ "schema_version": "0.1",
416
+ "manifest_id": "scout-ai-pilot-export-v1",
417
+ "asset_id": ASSET_ID,
418
+ "created_at": timestamp,
419
+ "receipt_ref": f"{relative_output}/receipt.json",
420
+ "control_track_ref": f"{relative_output}/controls.json",
421
+ "outputs": export_outputs,
422
+ }
423
+ identity = {
424
+ "schema_version": "0.1",
425
+ "asset_id": ASSET_ID,
426
+ "asset_kind": "character",
427
+ "identity": {
428
+ "subject_id": "scout-robot",
429
+ "reference_hashes": [idle_hash],
430
+ "camera": {"projection": "orthographic", "width": width, "height": height, "focal_length": 1},
431
+ "coordinate_system": "screen_y_down",
432
+ "scale": 1,
433
+ "pivot": {"x": reference_pivot[0] / width, "y": reference_pivot[1] / height, "space": "normalized_canvas"},
434
+ "palette_lock": {"colors": ["#f7e4c6", "#f47c15", "#121116", "#18d8f8"], "delta_e_tolerance": 28},
435
+ "style_profile": "pixel-art scout robot pilot",
436
+ "lighting_profile": "flat game sprite lighting",
437
+ },
438
+ "derivation": {"origin": "ai_generated", "generator": {"model": provider_model, "task_id": provider_task_id, "prompt_hash": prompt_hash}, "source_refs": ["provenance.json", "receipt-reconstruction.json"]},
439
+ "provenance_ref": "provenance.json",
440
+ "notes": "Identity controls are a post-hoc ingest representation. They bind output hashes but do not prove provider-native generation controls.",
441
+ }
442
+ action_set = {
443
+ "schema_version": "0.1",
444
+ "asset_identity": ASSET_ID,
445
+ "actions": [{"action_id": "walk-pilot", "fps": 8, "frames": [f"frames/scout-{frame_id}.png" for frame_id, _, _ in FRAME_ORDER], "loop": True, "events": ["foot_contact_right", "passing", "foot_contact_left"]}],
446
+ "invariants": {"canvas_width": width, "canvas_height": height, "pivot_tolerance_px": pivot_tolerance, "footline_tolerance_px": footline_tolerance, "bbox_drift_tolerance_px": bbox_tolerance, "min_alpha_pixels": min_alpha},
447
+ }
448
+ frame_geometry = {
449
+ "schema_version": "0.1",
450
+ "asset_identity": ASSET_ID,
451
+ "canvas": {"width": width, "height": height, "color_space": "srgb", "alpha_mode": "straight"},
452
+ "invariants": {"pivot_tolerance_px": pivot_tolerance, "footline_tolerance_px": footline_tolerance, "bbox_drift_tolerance_px": bbox_tolerance, "min_alpha_pixels": min_alpha, "allow_external_opaque_pixels": False},
453
+ "frames": geometry_frames,
454
+ }
455
+ candidate = {
456
+ "schema_version": "0.1",
457
+ "candidate_id": "scout-ai-pilot-candidate-v1",
458
+ "asset_id": ASSET_ID,
459
+ "created_at": timestamp,
460
+ "artifact_intake": {"registry": "artifact-adapter-registry.json", "receipt": f"{relative_output}/receipt.json", "controls": f"{relative_output}/controls.json", "export_manifest": f"{relative_output}/export.json"},
461
+ "consistency": {"asset_identity": f"{relative_output}/asset-identity.json", "action_set": f"{relative_output}/action-set.json", "frame_geometry": f"{relative_output}/frame-geometry.json"},
462
+ "runtime": {"target": "canvas", "review_required": True},
463
+ }
464
+ reconstruction = {
465
+ "artifact": ASSET_ID,
466
+ "status": "incomplete_provider_receipt",
467
+ "created_at": timestamp,
468
+ "known": ["output bytes", "output SHA-256", provider["reconstruction_source"], "post-hoc control capsule hash"],
469
+ "unknown": ["provider-native request ID", "provider-native prompt record", "provider-native seed", "provider-native output receipt"],
470
+ "governance": {"authority": "ai_generated", "human_review": "required", "production_approved": False, "attestation_approval": False},
471
+ }
472
+ lab_evidence = {
473
+ "pilot_id": PILOT_ID,
474
+ "asset_id": ASSET_ID,
475
+ "created_at": timestamp,
476
+ "state": "review_required",
477
+ "authority": "ai_generated",
478
+ "production_approved": False,
479
+ "runtime_verified": False,
480
+ "limits": [provider["lab_limit"], "Provider-native generation receipt was unavailable", "Runtime proof is not yet present", "No human approval exists"],
481
+ "frames": [
482
+ {"id": frame_id, "label": role.replace("_", " "), "path": f"frames/scout-{frame_id}.png", "url": urls.get(frame_id), "sha256": frames[frame_id]["sha256"], "alpha_bbox": frames[frame_id]["alpha_bbox"]}
483
+ for frame_id, role, _ in FRAME_ORDER
484
+ ],
485
+ }
486
+ for filename, document in (
487
+ ("controls.json", controls),
488
+ ("provenance.json", provenance),
489
+ ("receipt.json", receipt),
490
+ ("export.json", export),
491
+ ("asset-identity.json", identity),
492
+ ("action-set.json", action_set),
493
+ ("frame-geometry.json", frame_geometry),
494
+ ("candidate.json", candidate),
495
+ ("receipt-reconstruction.json", reconstruction),
496
+ ("devlab-pilot-evidence.json", lab_evidence),
497
+ ):
498
+ write_json(output / filename, document)
499
+ print(json.dumps({"status": "built", "root": str(root), "output": relative_output, "asset_id": ASSET_ID, "frame_count": len(frames), "alpha_geometry": {"footline_range": [min(footlines), max(footlines)], "pivot_tolerance_px": pivot_tolerance, "footline_tolerance_px": footline_tolerance, "bbox_drift_tolerance_px": bbox_tolerance}}, indent=2))
500
+ return 0
501
+
502
+
503
+ if __name__ == "__main__":
504
+ raise SystemExit(main())
@@ -34,14 +34,21 @@ def parse_json(path: Path) -> object:
34
34
  return json.loads(path.read_text(encoding="utf-8"))
35
35
 
36
36
 
37
- def reject_symlink_components(path: Path) -> None:
38
- current = Path(path.anchor) if path.anchor else Path(".")
39
- for part in path.parts:
40
- if part in (path.anchor, ""):
41
- continue
37
+ def reject_symlink_within_root(root: Path, relative: Path, label: str) -> None:
38
+ """Reject links inside an untrusted root, not symlinked system parents.
39
+
40
+ macOS exposes /var as a symlink to /private/var. A fixture rooted in mktemp
41
+ must be valid when the root itself is a real directory, while an evidence
42
+ path must never traverse a symlink at or below that root.
43
+ """
44
+ root = root.absolute()
45
+ if root.is_symlink():
46
+ raise ValueError(f"symlink root is not allowed: {label}")
47
+ current = root
48
+ for part in relative.parts:
42
49
  current = current / part
43
50
  if current.is_symlink():
44
- raise ValueError(f"symlink path component is not allowed: {path}")
51
+ raise ValueError(f"symlink path component is not allowed: {label}")
45
52
 
46
53
 
47
54
  def safe_relative_file(root: Path, relative: object, label: str) -> Path:
@@ -53,7 +60,7 @@ def safe_relative_file(root: Path, relative: object, label: str) -> Path:
53
60
  if any(part in ("", ".", "..") for part in candidate.parts):
54
61
  raise ValueError(f"{label} contains unsafe path components")
55
62
  joined = root / candidate
56
- reject_symlink_components(joined)
63
+ reject_symlink_within_root(root, candidate, label)
57
64
  resolved_root = root.resolve()
58
65
  resolved = joined.resolve(strict=False)
59
66
  if resolved != resolved_root and resolved_root not in resolved.parents:
@@ -81,8 +88,10 @@ def verify(scene_dir: Path, task_dir: Path, runtime_evidence_name: str, max_age_
81
88
  raise ValueError("scene directory name is unsafe")
82
89
  if not SAFE_NAME.fullmatch(task_dir.name):
83
90
  raise ValueError("task directory name is unsafe")
84
- reject_symlink_components(scene_dir)
85
- reject_symlink_components(task_dir)
91
+ if scene_dir.is_symlink():
92
+ raise ValueError("scene directory symlink is not allowed")
93
+ if task_dir.is_symlink():
94
+ raise ValueError("task directory symlink is not allowed")
86
95
  manifest_path = safe_relative_file(scene_dir, "manifest.json", "manifest.json")
87
96
  source_manifest = parse_json(manifest_path)
88
97
  if not isinstance(source_manifest, dict):