motionloom 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/AGENTS.md +3 -1
  2. package/CHANGELOG.md +68 -0
  3. package/CONTRIBUTING.md +3 -1
  4. package/README.md +20 -4
  5. package/SECURITY.md +5 -5
  6. package/SKILL.md +15 -5
  7. package/agent-card.json +77 -14
  8. package/agent-surfaces.json +30 -7
  9. package/artifact-adapter-registry.json +568 -20
  10. package/bin/motionloom.mjs +25 -2
  11. package/capability-registry.json +58 -234
  12. package/dev-lab/public/devlab.js +36 -3
  13. package/dev-lab/public/index.html +6 -2
  14. package/docs/ACTION-SEPARATION.md +104 -0
  15. package/docs/ASSET-GENERATION-PLANNER.md +101 -0
  16. package/docs/BRANCH-PROTECTION.md +44 -0
  17. package/docs/EXTERNAL-CORPUS.md +26 -0
  18. package/docs/STATUS.md +3 -2
  19. package/docs/audits/field-test-after-hardening-2026-08-21.md +25 -0
  20. package/docs/releases/2.6.1.md +57 -0
  21. package/docs/releases/2.7.0.md +98 -0
  22. package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.00.json +32 -0
  23. package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.01.json +32 -0
  24. package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.02.json +32 -0
  25. package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.03.json +32 -0
  26. package/examples/agent-consumer/asset-consistency/action-sequence/hero-walk-action-manifest.json +81 -0
  27. package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.00.json +26 -0
  28. package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.01.json +26 -0
  29. package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.02.json +26 -0
  30. package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.03.json +26 -0
  31. package/examples/agent-consumer/asset-planning/pixellab-hero-256x448-request.json +43 -0
  32. package/examples/agent-consumer/devlab-live-sprite/README.md +30 -0
  33. package/examples/agent-consumer/devlab-live-sprite/devlab-runtime.json +61 -0
  34. package/examples/agent-consumer/devlab-live-sprite/frames/idle-00.png +0 -0
  35. package/examples/agent-consumer/devlab-live-sprite/frames/idle-01.png +0 -0
  36. package/examples/agent-consumer/devlab-live-sprite/frames/idle-02.png +0 -0
  37. package/examples/agent-consumer/devlab-live-sprite/frames/reverse-00.png +0 -0
  38. package/examples/agent-consumer/devlab-live-sprite/frames/reverse-01.png +0 -0
  39. package/examples/agent-consumer/devlab-live-sprite/frames/reverse-02.png +0 -0
  40. package/examples/agent-consumer/frame-generation-lock/hero-walk-lock.json +97 -0
  41. package/package.json +23 -9
  42. package/references/multi-frame-asset-generation.md +144 -0
  43. package/schemas/action-separation-verifier-evidence.schema.json +43 -0
  44. package/schemas/action-sequence-manifest.schema.json +48 -0
  45. package/schemas/artifact-adapter-registry.schema.json +1 -1
  46. package/schemas/asset-adaptation.schema.json +21 -0
  47. package/schemas/asset-generation-plan.schema.json +77 -0
  48. package/schemas/asset-generation-request.schema.json +104 -0
  49. package/schemas/frame-envelope.schema.json +62 -0
  50. package/schemas/frame-generation-lock.schema.json +189 -0
  51. package/scripts/action-separation.py +410 -0
  52. package/scripts/asset-adapt.mjs +92 -0
  53. package/scripts/asset-generation-plan.py +613 -0
  54. package/scripts/browser_review_consistency.py +64 -0
  55. package/scripts/fetch-project-corpus.py +91 -0
  56. package/scripts/frame-generation-lock.py +358 -0
  57. package/scripts/frame-set-preflight.py +264 -0
  58. package/scripts/package-consumer-smoke.mjs +20 -0
  59. package/scripts/quality-gate.py +7 -0
  60. package/scripts/release-verify.py +25 -0
  61. package/scripts/report-contract.py +1 -1
  62. package/scripts/report.py +19 -4
  63. package/scripts/resolve-task-bundle.py +11 -3
  64. package/scripts/review-hook.py +51 -2
  65. package/scripts/skill-doctor.py +42 -0
  66. package/src/output/browser-review-smoke/browser-review.json +6 -6
  67. package/tests/scripts/run_tests.py +45 -2
  68. package/tests/scripts/test_asset_adapt.py +47 -0
  69. package/tests/scripts/test_asset_consistency.py +77 -4
  70. package/tests/scripts/test_asset_generation_plan.py +250 -0
  71. package/tests/scripts/test_attestation.py +24 -8
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env python3
2
+ """Fail-closed preflight for Agent-generated multi-frame source assets.
3
+
4
+ This wrapper reuses MotionLoom's deterministic frame-geometry measurements, then
5
+ adds generation-time rules that are intentionally stricter than a generic asset
6
+ inspection: source frames must be isolated canvases, scale drift is blocking,
7
+ and the measured alpha bounds must preserve the declared transparent guard band.
8
+
9
+ It never grants provenance authority, artistic approval or production approval.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import importlib.util
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+
22
+ ROOT = Path(__file__).resolve().parents[1]
23
+ ASSET_CONSISTENCY = ROOT / "scripts" / "asset-consistency.py"
24
+ ACTION_SEPARATION = ROOT / "scripts" / "action-separation.py"
25
+
26
+
27
+ def load_action_separation():
28
+ spec = importlib.util.spec_from_file_location("motionloom_action_separation", ACTION_SEPARATION)
29
+ if spec is None or spec.loader is None:
30
+ raise RuntimeError("cannot load action-separation.py")
31
+ module = importlib.util.module_from_spec(spec)
32
+ sys.modules[spec.name] = module
33
+ spec.loader.exec_module(module)
34
+ return module
35
+
36
+
37
+ def load_asset_consistency():
38
+ spec = importlib.util.spec_from_file_location("motionloom_asset_consistency", ASSET_CONSISTENCY)
39
+ if spec is None or spec.loader is None:
40
+ raise RuntimeError("cannot load asset-consistency.py")
41
+ module = importlib.util.module_from_spec(spec)
42
+ sys.modules[spec.name] = module
43
+ spec.loader.exec_module(module)
44
+ return module
45
+
46
+
47
+ AC = load_asset_consistency()
48
+ AS = load_action_separation()
49
+
50
+
51
+ def _error(code: str, message: str, path: str = "") -> dict[str, str]:
52
+ return {"severity": "error", "code": code, "message": message, "path": path}
53
+
54
+
55
+ def _normalise_rect(value: dict[str, Any]) -> dict[str, int]:
56
+ return {key: int(value[key]) for key in ("x", "y", "width", "height")}
57
+
58
+
59
+ def _contains(outer: dict[str, int], inner: dict[str, int]) -> bool:
60
+ return (
61
+ inner["x"] >= outer["x"]
62
+ and inner["y"] >= outer["y"]
63
+ and inner["x"] + inner["width"] <= outer["x"] + outer["width"]
64
+ and inner["y"] + inner["height"] <= outer["y"] + outer["height"]
65
+ )
66
+
67
+
68
+ def _shrink(rect: dict[str, int], margin: int) -> dict[str, int] | None:
69
+ width = rect["width"] - 2 * margin
70
+ height = rect["height"] - 2 * margin
71
+ if width <= 0 or height <= 0:
72
+ return None
73
+ return {
74
+ "x": rect["x"] + margin,
75
+ "y": rect["y"] + margin,
76
+ "width": width,
77
+ "height": height,
78
+ }
79
+
80
+
81
+ def validate(
82
+ document: dict[str, Any],
83
+ root: Path,
84
+ allow_shared_source: bool = False,
85
+ action_manifest: dict[str, Any] | None = None,
86
+ ) -> dict[str, Any]:
87
+ base = AC.validate_frame_geometry(document, root)
88
+ errors = list(base.get("errors", []))
89
+ warnings = list(base.get("warnings", []))
90
+ frames = document.get("frames") if isinstance(document.get("frames"), list) else []
91
+ canvas = document.get("canvas") if isinstance(document.get("canvas"), dict) else {}
92
+ canvas_width = int(canvas.get("width", 0) or 0)
93
+ canvas_height = int(canvas.get("height", 0) or 0)
94
+
95
+ measurements_by_id = {
96
+ str(item.get("frame_id")): item
97
+ for item in base.get("metrics", {}).get("measurements", [])
98
+ if isinstance(item, dict)
99
+ }
100
+
101
+ seen_images: dict[str, int] = {}
102
+ for index, frame in enumerate(frames):
103
+ if not isinstance(frame, dict):
104
+ continue
105
+ prefix = f"frames[{index}]"
106
+ image_value = str(frame.get("image", ""))
107
+ if image_value:
108
+ if image_value in seen_images and not allow_shared_source:
109
+ errors.append(
110
+ _error(
111
+ "shared_source_image",
112
+ f"generated source frame reuses image {image_value!r}; use one isolated image per source frame",
113
+ f"{prefix}.image",
114
+ )
115
+ )
116
+ seen_images[image_value] = index
117
+
118
+ rect = frame.get("rect") if isinstance(frame.get("rect"), dict) else None
119
+ if rect and canvas_width > 0 and canvas_height > 0 and not allow_shared_source:
120
+ expected = {"x": 0, "y": 0, "width": canvas_width, "height": canvas_height}
121
+ try:
122
+ actual = _normalise_rect(rect)
123
+ except (KeyError, TypeError, ValueError):
124
+ actual = {}
125
+ if actual != expected:
126
+ errors.append(
127
+ _error(
128
+ "non_isolated_source",
129
+ f"generated source frame rect must own the full {canvas_width}x{canvas_height} canvas before atlas packing",
130
+ f"{prefix}.rect",
131
+ )
132
+ )
133
+
134
+ frame_id = str(frame.get("frame_id", ""))
135
+ measurement = measurements_by_id.get(frame_id)
136
+ safe_rect_value = frame.get("safe_rect") if isinstance(frame.get("safe_rect"), dict) else None
137
+ if measurement and safe_rect_value:
138
+ try:
139
+ safe_rect = _normalise_rect(safe_rect_value)
140
+ bbox = _normalise_rect(measurement["alpha_bbox"])
141
+ margin = int(frame.get("bleed_margin_px", 0) or 0)
142
+ guarded = _shrink(safe_rect, margin)
143
+ except (KeyError, TypeError, ValueError):
144
+ guarded = None
145
+ bbox = None
146
+ if guarded is None:
147
+ errors.append(
148
+ _error(
149
+ "invalid_guard_band",
150
+ "safe_rect is too small for the declared bleed_margin_px",
151
+ f"{prefix}.bleed_margin_px",
152
+ )
153
+ )
154
+ elif bbox is not None and not _contains(guarded, bbox):
155
+ errors.append(
156
+ _error(
157
+ "guard_band_violation",
158
+ f"measured alpha bbox {bbox} leaves less than {frame.get('bleed_margin_px', 0)}px inside safe_rect {safe_rect}",
159
+ f"{prefix}.safe_rect",
160
+ )
161
+ )
162
+
163
+ action_result = None
164
+ if action_manifest is not None:
165
+ action_result = AS.validate_manifest(action_manifest, root)
166
+ errors.extend(action_result.get("errors", []))
167
+ warnings.extend(action_result.get("warnings", []))
168
+
169
+ remaining_warnings: list[dict[str, Any]] = []
170
+ for item in warnings:
171
+ if isinstance(item, dict) and item.get("code") == "bbox_drift":
172
+ promoted = dict(item)
173
+ promoted["severity"] = "error"
174
+ promoted["message"] = f"{promoted.get('message', 'bbox drift exceeds tolerance')}; generated frame scale drift blocks preflight"
175
+ errors.append(promoted)
176
+ else:
177
+ remaining_warnings.append(item)
178
+
179
+ return {
180
+ "contract": "generated_frame_set_preflight",
181
+ "ready": not errors,
182
+ "errors": errors,
183
+ "warnings": remaining_warnings,
184
+ "metrics": {
185
+ **base.get("metrics", {}),
186
+ "isolated_source_required": not allow_shared_source,
187
+ "unique_source_images": len(seen_images),
188
+ "action_manifest": action_result.get("metrics") if action_result else None,
189
+ },
190
+ "approval": False,
191
+ }
192
+
193
+
194
+ def main(argv: list[str] | None = None) -> int:
195
+ parser = argparse.ArgumentParser(description=__doc__)
196
+ parser.add_argument("--input", required=True, help="frame-geometry contract JSON")
197
+ parser.add_argument("--root", default=".", help="root used to resolve frame paths")
198
+ parser.add_argument("--allow-shared-source", action="store_true", help="for imported/shared canvases only; not recommended for generated source frames")
199
+ parser.add_argument("--action-manifest", help="action-sequence manifest; validates frame envelopes and action separation")
200
+ parser.add_argument("--json", action="store_true", dest="as_json")
201
+ args = parser.parse_args(argv)
202
+
203
+ try:
204
+ document = json.loads(Path(args.input).read_text(encoding="utf-8"))
205
+ except (OSError, json.JSONDecodeError) as exc:
206
+ result = {
207
+ "contract": "generated_frame_set_preflight",
208
+ "ready": False,
209
+ "errors": [_error("invalid_input", str(exc), args.input)],
210
+ "warnings": [],
211
+ "metrics": {},
212
+ "approval": False,
213
+ }
214
+ else:
215
+ if not isinstance(document, dict):
216
+ result = {
217
+ "contract": "generated_frame_set_preflight",
218
+ "ready": False,
219
+ "errors": [_error("invalid_input", "contract root must be an object", args.input)],
220
+ "warnings": [],
221
+ "metrics": {},
222
+ "approval": False,
223
+ }
224
+ else:
225
+ action_manifest = None
226
+ if args.action_manifest:
227
+ try:
228
+ action_manifest = json.loads(Path(args.action_manifest).read_text(encoding="utf-8"))
229
+ except (OSError, json.JSONDecodeError) as exc:
230
+ result = {
231
+ "contract": "generated_frame_set_preflight",
232
+ "ready": False,
233
+ "errors": [_error("invalid_action_manifest", str(exc), args.action_manifest)],
234
+ "warnings": [],
235
+ "metrics": {},
236
+ "approval": False,
237
+ }
238
+ action_manifest = None
239
+ if action_manifest is not None and not isinstance(action_manifest, dict):
240
+ result = {
241
+ "contract": "generated_frame_set_preflight",
242
+ "ready": False,
243
+ "errors": [_error("invalid_action_manifest", "action manifest root must be an object", args.action_manifest)],
244
+ "warnings": [],
245
+ "metrics": {},
246
+ "approval": False,
247
+ }
248
+ action_manifest = None
249
+ if action_manifest is not None or not args.action_manifest:
250
+ result = validate(document, Path(args.root).resolve(), args.allow_shared_source, action_manifest)
251
+
252
+ if args.as_json:
253
+ print(json.dumps(result, indent=2, ensure_ascii=False))
254
+ else:
255
+ print(f"generated frame-set preflight: {'PASS' if result['ready'] else 'FAIL'}")
256
+ for item in result.get("errors", []):
257
+ print(f"ERROR {item.get('code')}: {item.get('message')}")
258
+ for item in result.get("warnings", []):
259
+ print(f"WARN {item.get('code')}: {item.get('message')}")
260
+ return 0 if result["ready"] else 1
261
+
262
+
263
+ if __name__ == "__main__":
264
+ raise SystemExit(main())
@@ -69,6 +69,26 @@ try {
69
69
  if (!fs.existsSync(path.join(installedRoot, "dev-lab", "public", "scenes", scene, "browser-review.json"))) {
70
70
  throw new Error("installed Dev Lab did not prepare the consumer scene");
71
71
  }
72
+ const requestSource = path.join(installedRoot, "examples", "agent-consumer", "asset-planning", "pixellab-hero-256x448-request.json");
73
+ const requestCopy = path.join(consumer, "asset-generation-request.json");
74
+ const registryCopy = path.join(consumer, "artifact-adapter-registry.json");
75
+ fs.copyFileSync(requestSource, requestCopy);
76
+ fs.copyFileSync(path.join(installedRoot, "artifact-adapter-registry.json"), registryCopy);
77
+ const plan = JSON.parse(run(bin, ["asset-generation-plan", "plan", "--request", requestCopy, "--registry", registryCopy, "--project-root", consumer.toString(), "--json"], { cwd: consumer }));
78
+ if (plan.contract !== "motionloom-asset-generation-plan" || plan.schema_version !== "0.2" || plan.producer !== "MotionLoom" || plan.approval !== false) throw new Error("installed planner contract failed");
79
+ if (!Array.isArray(plan.recommendations) || plan.recommendations.length === 0) throw new Error("installed planner returned no normal-planning recommendations");
80
+ if (!plan.recommendations.some((item) => item.recommendation_status === "recommended" && item.execution_status === "provisional")) throw new Error("installed planner lost provisional recommendation/execution separation");
81
+ if (!plan.agent_guidance || plan.agent_guidance.recommended_by !== "MotionLoom") throw new Error("installed planner omitted MotionLoom agent guidance");
82
+ const humanPlan = run(bin, ["asset-generation-plan", "plan", "--request", requestCopy, "--registry", registryCopy, "--project-root", consumer.toString()], { cwd: consumer });
83
+ if (!humanPlan.includes("MotionLoom Project Assessment") || !humanPlan.includes("MotionLoom Recommendations")) throw new Error("installed planner human output lost MotionLoom identity");
84
+
85
+ const sourceImage = path.join(installedRoot, "examples", "agent-consumer", "asset-consistency", "assets", "hero-frame-00.png");
86
+ const adaptedImage = path.join(consumer, "adapted-frame.png");
87
+ const adaptationReport = path.join(consumer, "adaptation-report.json");
88
+ const adaptation = JSON.parse(run(bin, ["asset-adapt", "pad", "--input", sourceImage, "--output", adaptedImage, "--width", "256", "--height", "448", "--anchor", "footline", "--report", adaptationReport, "--json"], { cwd: consumer }));
89
+ if (adaptation.contract !== "motionloom-asset-adaptation" || adaptation.approval !== false || adaptation.output.canvas[0] !== 256 || adaptation.output.canvas[1] !== 448) throw new Error("installed Node asset-adapt contract failed");
90
+ if (!fs.existsSync(adaptedImage) || !fs.existsSync(adaptationReport)) throw new Error("installed asset-adapt omitted output/report");
91
+
72
92
  run(process.execPath, ["--input-type=module", "-e", "await import('playwright'); await import('vite');"], { cwd: consumer });
73
93
  console.log(JSON.stringify({ status: "pass", project_root: init.project_root, installed_root: installedRoot }, null, 2));
74
94
  } finally {
@@ -19,6 +19,7 @@ ROOT = Path(__file__).resolve().parents[1]
19
19
  SAFE_SCENE = re.compile(r"^[A-Za-z0-9._-]+$")
20
20
  sys.path.insert(0, str(ROOT / "scripts"))
21
21
  from intelligence import validate_task_benchmark, validate_task_intelligence, validate_task_p1 # noqa: E402
22
+ from browser_review_consistency import candidate_consistency_errors # noqa: E402
22
23
  sys.path.insert(0, str(ROOT / "src"))
23
24
  from core.spec import validate_spec # noqa: E402
24
25
 
@@ -321,6 +322,12 @@ def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = F
321
322
  issues.append("browser review candidate scene mismatch")
322
323
  if task_dir:
323
324
  task = _json(task_dir / "task.json")
325
+ issues.extend(candidate_consistency_errors(
326
+ scene_dir / "browser-review.json",
327
+ task_dir / "browser-review.json",
328
+ expected_task_id=task.get("task_id"),
329
+ expected_scene=scene_dir.name,
330
+ ))
324
331
  if candidate.get("task_id") != task.get("task_id"):
325
332
  issues.append("browser review candidate task_id mismatch")
326
333
  if candidate.get("source_sha256") != source_sha:
@@ -20,6 +20,7 @@ def main() -> int:
20
20
  parser.add_argument("--changelog", default=str(ROOT / "CHANGELOG.md"))
21
21
  parser.add_argument("--release-note", default="")
22
22
  parser.add_argument("--tag", default="", help="Optional Git tag; accepts v<version> or <version>")
23
+ parser.add_argument("--capability-registry", default=str(ROOT / "capability-registry.json"))
23
24
  args = parser.parse_args()
24
25
 
25
26
  errors: list[str] = []
@@ -43,6 +44,30 @@ def main() -> int:
43
44
  if args.tag and args.tag.removeprefix("v") != expected:
44
45
  errors.append(f"tag {args.tag!r} does not match version {expected!r}")
45
46
 
47
+ registry_path = Path(args.capability_registry).resolve()
48
+ try:
49
+ registry = json.loads(registry_path.read_text(encoding="utf-8"))
50
+ expected_registry_id = f"registry-{package.get('name', 'motionloom')}-{expected}"
51
+ if registry.get("registry_id") != expected_registry_id:
52
+ errors.append(
53
+ f"capability registry id {registry.get('registry_id')!r} does not match {expected_registry_id!r}"
54
+ )
55
+ capabilities = registry.get("capabilities")
56
+ if not isinstance(capabilities, list) or not capabilities:
57
+ errors.append("capability registry must contain a non-empty capabilities array")
58
+ else:
59
+ mismatched = [
60
+ str(item.get("id", "<unknown>"))
61
+ for item in capabilities
62
+ if item.get("adapter_version") != expected
63
+ ]
64
+ if mismatched:
65
+ errors.append(
66
+ "capability registry adapter_version mismatch: " + ", ".join(mismatched)
67
+ )
68
+ except (OSError, json.JSONDecodeError, AttributeError) as exc:
69
+ errors.append(f"capability registry is invalid: {exc}")
70
+
46
71
  report = {"status": "fail" if errors else "pass", "version": actual, "expected_version": expected, "errors": errors}
47
72
  print(json.dumps(report, indent=2))
48
73
  return 1 if errors else 0
@@ -91,7 +91,7 @@ def main() -> int:
91
91
  passing: list[Path] = []
92
92
  for task_dir in complete:
93
93
  result = subprocess.run(
94
- [sys.executable, str(report_script), "check", "--task-dir", str(task_dir)],
94
+ [sys.executable, str(report_script), "check", "--task-dir", str(task_dir), "--root", str(root)],
95
95
  cwd=root,
96
96
  capture_output=True,
97
97
  text=True,
package/scripts/report.py CHANGED
@@ -12,8 +12,11 @@ import sys
12
12
  from datetime import datetime, timezone
13
13
  from pathlib import Path
14
14
 
15
+ from browser_review_consistency import candidate_consistency_errors
15
16
 
16
- ROOT = Path(__file__).resolve().parents[1]
17
+
18
+ PACKAGE_ROOT = Path(__file__).resolve().parents[1]
19
+ ROOT = PACKAGE_ROOT
17
20
  EVIDENCE_ARTIFACTS = (
18
21
  "semantic-lint-benchmark.json",
19
22
  "evidence-verifier-report.json",
@@ -56,7 +59,7 @@ def project_memory_path() -> Path:
56
59
 
57
60
 
58
61
  def asset_provenance_module():
59
- path = ROOT / "scripts" / "asset-provenance.py"
62
+ path = PACKAGE_ROOT / "scripts" / "asset-provenance.py"
60
63
  loader = importlib.util.spec_from_file_location("motionloom_asset_provenance", path)
61
64
  module = importlib.util.module_from_spec(loader)
62
65
  loader.loader.exec_module(module)
@@ -64,7 +67,7 @@ def asset_provenance_module():
64
67
 
65
68
 
66
69
  def asset_consistency_module():
67
- path = ROOT / "scripts" / "asset-consistency.py"
70
+ path = PACKAGE_ROOT / "scripts" / "asset-consistency.py"
68
71
  loader = importlib.util.spec_from_file_location("motionloom_asset_consistency", path)
69
72
  module = importlib.util.module_from_spec(loader)
70
73
  sys.modules[loader.name] = module
@@ -73,7 +76,7 @@ def asset_consistency_module():
73
76
 
74
77
 
75
78
  def artifact_intake_module():
76
- path = ROOT / "scripts" / "artifact-intake.py"
79
+ path = PACKAGE_ROOT / "scripts" / "artifact-intake.py"
77
80
  loader = importlib.util.spec_from_file_location("motionloom_artifact_intake", path)
78
81
  module = importlib.util.module_from_spec(loader)
79
82
  sys.modules[loader.name] = module
@@ -450,6 +453,14 @@ def approval_contract_errors(task_dir: Path, task: dict, require_current: bool)
450
453
  return ["ready-for-PR task requires review.json"]
451
454
  if candidate.get("task_id") != task.get("task_id"):
452
455
  errors.append("browser-review candidate task_id does not match task.json")
456
+ scene_candidate_path = ROOT / "src" / "output" / str(task.get("scene", "")) / "browser-review.json"
457
+ if scene_candidate_path.is_file():
458
+ errors.extend(candidate_consistency_errors(
459
+ scene_candidate_path,
460
+ candidate_path,
461
+ expected_task_id=task.get("task_id"),
462
+ expected_scene=task.get("scene"),
463
+ ))
453
464
  if candidate.get("scene") != task.get("scene"):
454
465
  errors.append("browser-review candidate scene does not match task.json")
455
466
  task_review = task.get("browser_review") or {}
@@ -717,6 +728,7 @@ def render(args: argparse.Namespace) -> int:
717
728
 
718
729
 
719
730
  def main() -> int:
731
+ global ROOT
720
732
  parser = argparse.ArgumentParser(description=__doc__)
721
733
  sub = parser.add_subparsers(dest="command", required=True)
722
734
  init = sub.add_parser("init")
@@ -772,8 +784,11 @@ def main() -> int:
772
784
  structure_parser.set_defaults(func=record_structure)
773
785
  check_parser = sub.add_parser("check", help="Validate semantic completeness of a task bundle")
774
786
  check_parser.add_argument("--task-dir", required=True)
787
+ check_parser.add_argument("--root", default=str(ROOT), help="Repository root containing the canonical scene artifacts")
775
788
  check_parser.set_defaults(func=check_report)
776
789
  args = parser.parse_args()
790
+ if hasattr(args, "root"):
791
+ ROOT = Path(args.root).expanduser().resolve()
777
792
  return args.func(args)
778
793
 
779
794
 
@@ -4,7 +4,7 @@
4
4
  The resolver intentionally does not infer a task path from a scene slug. A
5
5
  bundle is eligible only when its direct ``artifacts/<task-id>/task.json``
6
6
  declares the requested scene and remains inside the repository artifacts root.
7
- Multiple matching bundles are rejected rather than ranked implicitly.
7
+ Multiple matching bundles are rejected rather than ranked implicitly. A task bundle with a browser-review candidate that conflicts with the canonical scene candidate is not eligible; the later quality/review gates still report that divergence when the bundle is checked directly.
8
8
  """
9
9
 
10
10
  from __future__ import annotations
@@ -44,6 +44,7 @@ def resolve_task_dirs(root: Path, scene: str) -> list[Path]:
44
44
  if not artifacts.is_dir() or artifacts.is_symlink():
45
45
  return []
46
46
  resolved_artifacts = artifacts.resolve()
47
+ scene_candidate = read_json(root / "src" / "output" / scene / "browser-review.json")
47
48
  matches: list[Path] = []
48
49
  for task_path in sorted(artifacts.glob("*/task.json")):
49
50
  if has_symlink_component(task_path, root):
@@ -53,8 +54,15 @@ def resolve_task_dirs(root: Path, scene: str) -> list[Path]:
53
54
  continue
54
55
  except (OSError, RuntimeError):
55
56
  continue
56
- if read_json(task_path).get("scene") == scene:
57
- matches.append(task_path.parent)
57
+ task = read_json(task_path)
58
+ if task.get("scene") != scene:
59
+ continue
60
+ task_candidate = read_json(task_path.parent / "browser-review.json")
61
+ if scene_candidate and task_candidate:
62
+ identity_fields = ("candidate_id", "task_id", "scene", "source_sha256", "context_sha256", "expires_at")
63
+ if any(scene_candidate.get(field) != task_candidate.get(field) for field in identity_fields):
64
+ continue
65
+ matches.append(task_path.parent)
58
66
  return matches
59
67
 
60
68
 
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  import hashlib
8
+ import os
8
9
  import json
9
10
  import re
10
11
  import shutil
@@ -14,6 +15,12 @@ from datetime import datetime, timedelta, timezone
14
15
  from pathlib import Path
15
16
  from urllib.parse import urlencode, urlsplit, urlunsplit
16
17
 
18
+ try:
19
+ from browser_review_consistency import candidate_consistency_errors
20
+ except ModuleNotFoundError: # Support importlib-based contract tests.
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
+ from browser_review_consistency import candidate_consistency_errors
23
+
17
24
  ROOT = Path(__file__).resolve().parents[1]
18
25
  SAFE_SCENE = re.compile(r"^[A-Za-z0-9._-]+$")
19
26
  SAFE_ANIMATION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
@@ -182,6 +189,20 @@ def runtime_bundle(scene_dir: Path) -> dict | None:
182
189
  review_policy = descriptor.get("review_policy")
183
190
  if not isinstance(review_policy, dict) or not isinstance(review_policy.get("require_all_animations"), bool):
184
191
  raise ValueError("devlab-runtime.json review_policy.require_all_animations must be boolean")
192
+ action_separation = descriptor.get("action_separation")
193
+ if action_separation is not None:
194
+ if not isinstance(action_separation, dict):
195
+ raise ValueError("devlab-runtime.json action_separation must be an object")
196
+ if action_separation.get("status") not in {"pass", "quarantined"}:
197
+ raise ValueError("devlab-runtime.json action_separation.status must be pass or quarantined")
198
+ if not isinstance(action_separation.get("action_id"), str) or not SAFE_ANIMATION.fullmatch(action_separation["action_id"]):
199
+ raise ValueError("devlab-runtime.json action_separation.action_id is invalid")
200
+ frame_count = action_separation.get("frame_count")
201
+ passing_count = action_separation.get("passing_frame_count")
202
+ if not isinstance(frame_count, int) or frame_count < 1 or not isinstance(passing_count, int) or passing_count < 0 or passing_count > frame_count:
203
+ raise ValueError("devlab-runtime.json action_separation frame counts are invalid")
204
+ if not isinstance(action_separation.get("forbidden_action_ids"), list):
205
+ raise ValueError("devlab-runtime.json action_separation.forbidden_action_ids must be an array")
185
206
 
186
207
  digest = hashlib.sha256()
187
208
  digest.update(b"motionloom-devlab-runtime-v1\0")
@@ -198,6 +219,7 @@ def runtime_bundle(scene_dir: Path) -> dict | None:
198
219
  "mode": mode,
199
220
  "files": sorted(resolved_files),
200
221
  "review_policy": {"require_all_animations": review_policy["require_all_animations"]},
222
+ "action_separation": action_separation,
201
223
  }
202
224
 
203
225
 
@@ -215,6 +237,7 @@ def runtime_review_payload(bundle: dict | None) -> dict:
215
237
  "bundle_sha256": bundle["bundle_sha256"],
216
238
  "animations": bundle["animations"],
217
239
  "review_policy": bundle["review_policy"],
240
+ "action_separation": bundle.get("action_separation"),
218
241
  }
219
242
 
220
243
 
@@ -334,8 +357,24 @@ def prepare(args: argparse.Namespace) -> int:
334
357
  )),
335
358
  })
336
359
  handoff_path.write_text(json.dumps(handoff, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
337
- subprocess.run([sys.executable, str(ROOT / "scripts/devlab.py"), scene, "--prepare-only", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
338
- subprocess.run([sys.executable, str(ROOT / "scripts/report.py"), "collect", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
360
+ project_env = os.environ.copy()
361
+ project_env["MOTIONLOOM_PROJECT_ROOT"] = str(ROOT)
362
+ subprocess.run(
363
+ [sys.executable, str(ROOT / "scripts/devlab.py"), scene, "--prepare-only", "--task-dir", str(task_dir)],
364
+ check=True,
365
+ capture_output=True,
366
+ text=True,
367
+ cwd=ROOT,
368
+ env=project_env,
369
+ )
370
+ subprocess.run(
371
+ [sys.executable, str(ROOT / "scripts/report.py"), "collect", "--task-dir", str(task_dir)],
372
+ check=True,
373
+ capture_output=True,
374
+ text=True,
375
+ cwd=ROOT,
376
+ env=project_env,
377
+ )
339
378
  print(json.dumps({
340
379
  "status": "review_required",
341
380
  "task_id": task["task_id"],
@@ -363,6 +402,12 @@ def validate(args: argparse.Namespace) -> int:
363
402
  bundle["bundle_sha256"] if bundle else None,
364
403
  )
365
404
  errors = []
405
+ errors.extend(candidate_consistency_errors(
406
+ scene_dir / "browser-review.json",
407
+ task_dir / "browser-review.json",
408
+ expected_task_id=task.get("task_id"),
409
+ expected_scene=task.get("scene"),
410
+ ))
366
411
  if not candidate.get("expires_at"):
367
412
  errors.append("browser-review candidate has no expiry")
368
413
  else:
@@ -446,17 +491,21 @@ def validate(args: argparse.Namespace) -> int:
446
491
 
447
492
 
448
493
  def main() -> int:
494
+ global ROOT
449
495
  parser = argparse.ArgumentParser(description=__doc__)
450
496
  sub = parser.add_subparsers(dest="command", required=True)
451
497
  p = sub.add_parser("prepare")
452
498
  p.add_argument("--task-dir", required=True)
499
+ p.add_argument("--root", default=str(ROOT), help="Repository root containing the canonical scene artifacts")
453
500
  p.add_argument("--lab-url", default="http://127.0.0.1:3300")
454
501
  p.set_defaults(func=prepare)
455
502
  v = sub.add_parser("validate")
456
503
  v.add_argument("--task-dir", required=True)
504
+ v.add_argument("--root", default=str(ROOT), help="Repository root containing the canonical scene artifacts")
457
505
  v.add_argument("--require-approved", action="store_true")
458
506
  v.set_defaults(func=validate)
459
507
  args = parser.parse_args()
508
+ ROOT = Path(args.root).expanduser().resolve()
460
509
  try:
461
510
  return args.func(args)
462
511
  except (KeyError, FileNotFoundError, json.JSONDecodeError, ValueError, subprocess.CalledProcessError) as exc:
@@ -7,6 +7,7 @@ import argparse
7
7
  import importlib.util
8
8
  import json
9
9
  import re
10
+ import subprocess
10
11
  import sys
11
12
  from pathlib import Path
12
13
 
@@ -71,9 +72,35 @@ def parse_frontmatter(text: str) -> dict[str, str] | None:
71
72
  return values
72
73
 
73
74
 
75
+ def chromium_executable() -> tuple[Path | None, str | None]:
76
+ probe = (
77
+ "import { chromium } from 'playwright'; "
78
+ "process.stdout.write(chromium.executablePath());"
79
+ )
80
+ try:
81
+ result = subprocess.run(
82
+ ["node", "--input-type=module", "--eval", probe],
83
+ cwd=ROOT,
84
+ capture_output=True,
85
+ text=True,
86
+ timeout=10,
87
+ )
88
+ except (OSError, subprocess.SubprocessError) as exc:
89
+ return None, str(exc)
90
+ if result.returncode != 0:
91
+ return None, (result.stderr or result.stdout).strip() or "Playwright import failed"
92
+ path = Path(result.stdout.strip())
93
+ return (path if path else None), None
94
+
95
+
74
96
  def run() -> int:
75
97
  parser = argparse.ArgumentParser(description=__doc__)
76
98
  parser.add_argument("--json", action="store_true", dest="as_json")
99
+ parser.add_argument(
100
+ "--runtime",
101
+ action="store_true",
102
+ help="also verify that the Playwright Chromium executable is installed",
103
+ )
77
104
  args = parser.parse_args()
78
105
  errors: list[dict] = []
79
106
  warnings: list[dict] = []
@@ -156,6 +183,21 @@ def run() -> int:
156
183
  except (FileNotFoundError, json.JSONDecodeError) as exc:
157
184
  errors.append({"code": "invalid_package_json", "message": str(exc)})
158
185
 
186
+ if args.runtime:
187
+ chromium_path, chromium_error = chromium_executable()
188
+ chromium_ready = chromium_path is not None and chromium_path.is_file()
189
+ checks.append({
190
+ "id": "runtime:chromium",
191
+ "status": "pass" if chromium_ready else "fail",
192
+ "path": str(chromium_path) if chromium_path else None,
193
+ })
194
+ if not chromium_ready:
195
+ detail = chromium_error or f"Chromium executable is missing: {chromium_path}"
196
+ errors.append({
197
+ "code": "missing_browser_executable",
198
+ "message": f"Playwright Chromium is unavailable ({detail}); run `npx playwright install chromium`.",
199
+ })
200
+
159
201
  cryptography_available = importlib.util.find_spec("cryptography") is not None
160
202
  checks.append({"id": "python-dependency:cryptography", "status": "pass" if cryptography_available else "fail"})
161
203
  if not cryptography_available: