motionloom 2.3.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 (102) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +78 -11
  3. package/ROADMAP.md +8 -5
  4. package/SKILL.md +24 -5
  5. package/agent-card.json +31 -5
  6. package/agent-surfaces.json +1 -1
  7. package/artifact-adapter-registry.json +53 -0
  8. package/bin/motionloom.mjs +29 -5
  9. package/docs/CHECKLIST.md +16 -0
  10. package/docs/STATUS.md +2 -2
  11. package/docs/audits/data/deep-stress-latest.json +65 -65
  12. package/docs/releases/2.4.0.md +31 -0
  13. package/docs/releases/npm-publish-from-workstation.md +19 -8
  14. package/docs/research/agent-skill-ecosystem-notes.md +47 -0
  15. package/docs/research/ai-animation-tools-2026-notes.md +81 -0
  16. package/docs/research/ai-animation-tools-2026-report.md +168 -0
  17. package/docs/research/ai-pilot-ingest-notes.md +29 -0
  18. package/examples/agent-consumer/ai-pilot-scout/CHATGPT-HANDOFF.md +70 -0
  19. package/examples/agent-consumer/ai-pilot-scout/CODEX-HANDOFF.md +80 -0
  20. package/examples/agent-consumer/ai-pilot-scout/README.md +39 -0
  21. package/examples/agent-consumer/ai-pilot-scout/chatgpt-geometry-correction-prompt.md +55 -0
  22. package/examples/agent-consumer/ai-pilot-scout/chatgpt-pose-generation-prompt.md +78 -0
  23. package/examples/agent-consumer/ai-pilot-scout/partial-handoff.json +73 -0
  24. package/examples/agent-consumer/artifact-intake/hero-motion-controls.json +15 -0
  25. package/examples/agent-consumer/artifact-intake/hero-motion-export.json +14 -0
  26. package/examples/agent-consumer/artifact-intake/hero-motion-provenance.json +20 -0
  27. package/examples/agent-consumer/artifact-intake/hero-motion-receipt.json +16 -0
  28. package/examples/agent-consumer/asset-consistency/assets/forest-back.png +0 -0
  29. package/examples/agent-consumer/asset-consistency/assets/forest-front.png +0 -0
  30. package/examples/agent-consumer/asset-consistency/assets/forest-mid.png +0 -0
  31. package/examples/agent-consumer/asset-consistency/assets/hero-atlas.png +0 -0
  32. package/examples/agent-consumer/asset-consistency/assets/hero-frame-00.png +0 -0
  33. package/examples/agent-consumer/asset-consistency/assets/hero-frame-01.png +0 -0
  34. package/examples/agent-consumer/asset-consistency/assets/hero-frame-02.png +0 -0
  35. package/examples/agent-consumer/asset-consistency/assets/hero-frame-03.png +0 -0
  36. package/examples/agent-consumer/asset-consistency/forest-layered-map.json +53 -0
  37. package/examples/agent-consumer/asset-consistency/hero-atlas-contract.json +24 -0
  38. package/examples/agent-consumer/asset-consistency/hero-identity.json +51 -0
  39. package/examples/agent-consumer/asset-consistency/hero-walk-action-set.json +27 -0
  40. package/examples/agent-consumer/asset-consistency/hero-walk-frame-geometry.json +63 -0
  41. package/examples/agent-consumer/rig-compatibility/hero-walk-fixture-rig.json +13 -0
  42. package/examples/agent-consumer/rive-package-gate/README.md +20 -0
  43. package/examples/agent-consumer/runtime-candidate/hero-walk-candidate.json +18 -0
  44. package/examples/agent-consumer/runtime-pilot/action-set.json +19 -0
  45. package/examples/agent-consumer/runtime-pilot/asset-identity.json +44 -0
  46. package/examples/agent-consumer/runtime-pilot/candidate.json +21 -0
  47. package/examples/agent-consumer/runtime-pilot/controls.json +35 -0
  48. package/examples/agent-consumer/runtime-pilot/export.json +17 -0
  49. package/examples/agent-consumer/runtime-pilot/provenance.json +38 -0
  50. package/examples/agent-consumer/runtime-pilot/receipt.json +30 -0
  51. package/package.json +56 -4
  52. package/references/browser-review-contract.md +7 -1
  53. package/references/intelligence-core.md +22 -1
  54. package/rig-adapter-registry.json +39 -0
  55. package/schemas/action-set.schema.json +42 -0
  56. package/schemas/artifact-adapter-registry.schema.json +16 -0
  57. package/schemas/asset-identity.schema.json +117 -0
  58. package/schemas/asset-provenance.schema.json +1 -1
  59. package/schemas/atlas-contract.schema.json +48 -0
  60. package/schemas/control-track.schema.json +18 -0
  61. package/schemas/export-manifest.schema.json +19 -0
  62. package/schemas/frame-geometry.schema.json +79 -0
  63. package/schemas/generation-receipt.schema.json +20 -0
  64. package/schemas/layered-map.schema.json +75 -0
  65. package/schemas/rig-adapter-registry.schema.json +14 -0
  66. package/schemas/rig-compatibility.schema.json +53 -0
  67. package/schemas/rive-package-manifest.schema.json +43 -0
  68. package/schemas/runtime-candidate.schema.json +48 -0
  69. package/schemas/scene-manifest.schema.json +18 -0
  70. package/scripts/artifact-intake.py +408 -0
  71. package/scripts/asset-consistency.py +517 -0
  72. package/scripts/asset-provenance.py +8 -3
  73. package/scripts/build-ai-pilot.py +504 -0
  74. package/scripts/isolate-alpha-background.py +147 -0
  75. package/scripts/quality-gate.py +88 -2
  76. package/scripts/report.py +85 -0
  77. package/scripts/resolve-task-bundle.py +84 -0
  78. package/scripts/review-hook.py +26 -4
  79. package/scripts/rig-compatibility.py +251 -0
  80. package/scripts/rive-package-gate.py +245 -0
  81. package/scripts/runtime-candidate.py +301 -0
  82. package/scripts/setup.mjs +54 -26
  83. package/src/output/runtime-pilot-framer/asset-provenance.json +38 -0
  84. package/src/output/runtime-pilot-framer/browser-review.json +20 -0
  85. package/src/output/runtime-pilot-framer/framer-motion/runtime-telemetry.json +103 -0
  86. package/src/output/runtime-pilot-framer/manifest.json +31 -0
  87. package/src/output/runtime-pilot-framer/motion-spec.json +25 -0
  88. package/src/output/runtime-pilot-framer/runtime-evidence.json +96 -0
  89. package/src/output/runtime-pilot-framer/runtime-telemetry.json +100 -0
  90. package/src/output/runtime-pilot-framer/scene.jsx +52 -0
  91. package/src/output/runtime-pilot-framer/snapshot/.render-meta.json +9 -0
  92. package/src/output/runtime-pilot-framer/snapshot/frame-00.png +0 -0
  93. package/src/output/runtime-pilot-framer/snapshot/frame-100.png +0 -0
  94. package/src/output/runtime-pilot-framer/snapshot/frame-50.png +0 -0
  95. package/tests/scripts/run_tests.py +107 -0
  96. package/tests/scripts/test_ai_pilot_builder.py +128 -0
  97. package/tests/scripts/test_alpha_isolation.py +60 -0
  98. package/tests/scripts/test_artifact_intake.py +146 -0
  99. package/tests/scripts/test_asset_consistency.py +199 -0
  100. package/tests/scripts/test_rig_compatibility.py +105 -0
  101. package/tests/scripts/test_rive_package_gate.py +101 -0
  102. package/tests/scripts/test_runtime_candidate.py +95 -0
@@ -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")]),
@@ -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)