motionloom 2.6.1 → 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 (69) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +42 -0
  3. package/CONTRIBUTING.md +3 -1
  4. package/README.md +19 -3
  5. package/SECURITY.md +5 -5
  6. package/SKILL.md +6 -3
  7. package/agent-card.json +18 -4
  8. package/agent-surfaces.json +1 -1
  9. package/artifact-adapter-registry.json +568 -20
  10. package/bin/motionloom.mjs +21 -4
  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.7.0.md +98 -0
  21. package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.00.json +32 -0
  22. package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.01.json +32 -0
  23. package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.02.json +32 -0
  24. package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.03.json +32 -0
  25. package/examples/agent-consumer/asset-consistency/action-sequence/hero-walk-action-manifest.json +81 -0
  26. package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.00.json +26 -0
  27. package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.01.json +26 -0
  28. package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.02.json +26 -0
  29. package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.03.json +26 -0
  30. package/examples/agent-consumer/asset-planning/pixellab-hero-256x448-request.json +43 -0
  31. package/examples/agent-consumer/devlab-live-sprite/README.md +30 -0
  32. package/examples/agent-consumer/devlab-live-sprite/devlab-runtime.json +61 -0
  33. package/examples/agent-consumer/devlab-live-sprite/frames/idle-00.png +0 -0
  34. package/examples/agent-consumer/devlab-live-sprite/frames/idle-01.png +0 -0
  35. package/examples/agent-consumer/devlab-live-sprite/frames/idle-02.png +0 -0
  36. package/examples/agent-consumer/devlab-live-sprite/frames/reverse-00.png +0 -0
  37. package/examples/agent-consumer/devlab-live-sprite/frames/reverse-01.png +0 -0
  38. package/examples/agent-consumer/devlab-live-sprite/frames/reverse-02.png +0 -0
  39. package/examples/agent-consumer/frame-generation-lock/hero-walk-lock.json +14 -1
  40. package/package.json +15 -1
  41. package/references/multi-frame-asset-generation.md +37 -2
  42. package/schemas/action-separation-verifier-evidence.schema.json +43 -0
  43. package/schemas/action-sequence-manifest.schema.json +48 -0
  44. package/schemas/artifact-adapter-registry.schema.json +1 -1
  45. package/schemas/asset-adaptation.schema.json +21 -0
  46. package/schemas/asset-generation-plan.schema.json +77 -0
  47. package/schemas/asset-generation-request.schema.json +104 -0
  48. package/schemas/frame-envelope.schema.json +62 -0
  49. package/schemas/frame-generation-lock.schema.json +24 -1
  50. package/scripts/action-separation.py +410 -0
  51. package/scripts/asset-adapt.mjs +92 -0
  52. package/scripts/asset-generation-plan.py +613 -0
  53. package/scripts/browser_review_consistency.py +64 -0
  54. package/scripts/fetch-project-corpus.py +91 -0
  55. package/scripts/frame-generation-lock.py +35 -5
  56. package/scripts/frame-set-preflight.py +52 -2
  57. package/scripts/package-consumer-smoke.mjs +20 -0
  58. package/scripts/quality-gate.py +7 -0
  59. package/scripts/release-verify.py +25 -0
  60. package/scripts/report-contract.py +1 -1
  61. package/scripts/report.py +19 -4
  62. package/scripts/resolve-task-bundle.py +11 -3
  63. package/scripts/review-hook.py +51 -2
  64. package/scripts/skill-doctor.py +42 -0
  65. package/src/output/browser-review-smoke/browser-review.json +6 -6
  66. package/tests/scripts/run_tests.py +45 -2
  67. package/tests/scripts/test_asset_adapt.py +47 -0
  68. package/tests/scripts/test_asset_generation_plan.py +250 -0
  69. package/tests/scripts/test_attestation.py +24 -8
@@ -0,0 +1,64 @@
1
+ """Shared browser-review candidate consistency checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ # These fields determine which exact candidate was reviewed. The URL is
11
+ # intentionally excluded because a Dev Lab deployment may legitimately use a
12
+ # different host/base path while the candidate bytes remain identical.
13
+ IDENTITY_FIELDS = (
14
+ "candidate_id",
15
+ "task_id",
16
+ "scene",
17
+ "source_sha256",
18
+ "context_sha256",
19
+ "status",
20
+ "expires_at",
21
+ "runtime_review",
22
+ )
23
+
24
+
25
+ def read_json(path: Path) -> dict[str, Any]:
26
+ try:
27
+ value = json.loads(path.read_text(encoding="utf-8"))
28
+ except (OSError, json.JSONDecodeError):
29
+ return {}
30
+ return value if isinstance(value, dict) else {}
31
+
32
+
33
+ def candidate_consistency_errors(
34
+ scene_candidate_path: Path,
35
+ task_candidate_path: Path,
36
+ *,
37
+ expected_task_id: str | None = None,
38
+ expected_scene: str | None = None,
39
+ ) -> list[str]:
40
+ """Return fail-closed errors for scene/task browser-review divergence."""
41
+
42
+ errors: list[str] = []
43
+ if not scene_candidate_path.is_file():
44
+ return ["scene-level browser-review.json is required when a task bundle is used"]
45
+ if not task_candidate_path.is_file():
46
+ return ["task-level browser-review.json is required when a task bundle is used"]
47
+
48
+ scene_candidate = read_json(scene_candidate_path)
49
+ task_candidate = read_json(task_candidate_path)
50
+ if not scene_candidate:
51
+ errors.append("scene-level browser-review.json is invalid or empty")
52
+ if not task_candidate:
53
+ errors.append("task-level browser-review.json is invalid or empty")
54
+
55
+ for field in IDENTITY_FIELDS:
56
+ if scene_candidate.get(field) != task_candidate.get(field):
57
+ errors.append(f"scene/task browser-review {field} mismatch")
58
+
59
+ if expected_task_id is not None and task_candidate.get("task_id") != expected_task_id:
60
+ errors.append("task-level browser-review task_id does not match task.json")
61
+ if expected_scene is not None and task_candidate.get("scene") != expected_scene:
62
+ errors.append("task-level browser-review scene does not match scene directory")
63
+
64
+ return errors
@@ -0,0 +1,91 @@
1
+ """Fetch the explicitly requested external analyzer corpus without installing or executing it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import shutil
8
+ import subprocess
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+ DEFAULT_MANIFEST = ROOT / "tests/evals/project-corpus.json"
14
+
15
+
16
+ def now() -> str:
17
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
18
+
19
+
20
+ def run(command: list[str], cwd: Path | None = None) -> str:
21
+ result = subprocess.run(command, cwd=cwd, capture_output=True, text=True)
22
+ if result.returncode != 0:
23
+ detail = (result.stderr or result.stdout).strip()
24
+ raise RuntimeError(f"{' '.join(command)} failed: {detail}")
25
+ return result.stdout.strip()
26
+
27
+
28
+ def safe_destination(workspace: Path, relative: str) -> Path:
29
+ destination = (workspace / relative).resolve()
30
+ if workspace.resolve() not in destination.parents:
31
+ raise ValueError(f"manifest local_path escapes workspace: {relative}")
32
+ return destination
33
+
34
+
35
+ def main() -> int:
36
+ parser = argparse.ArgumentParser(description=__doc__)
37
+ parser.add_argument("--manifest", default=str(DEFAULT_MANIFEST))
38
+ parser.add_argument("--workspace", required=True, help="Directory that will contain external checkouts")
39
+ parser.add_argument("--project", action="append", default=[], help="Manifest project id; repeatable")
40
+ parser.add_argument("--all", action="store_true", help="Fetch every external project in the manifest")
41
+ parser.add_argument("--depth", type=int, default=1)
42
+ parser.add_argument("--refresh", action="store_true", help="Remove and re-fetch existing selected checkouts")
43
+ args = parser.parse_args()
44
+ if not args.all and not args.project:
45
+ parser.error("choose --all or at least one --project")
46
+ if args.depth < 1:
47
+ parser.error("--depth must be positive")
48
+
49
+ manifest_path = Path(args.manifest).expanduser().resolve()
50
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
51
+ cases = [case for case in manifest.get("projects", []) if case.get("external") is True]
52
+ selected = set(case["id"] for case in cases) if args.all else set(args.project)
53
+ known = {case["id"] for case in cases}
54
+ unknown = sorted(selected - known)
55
+ if unknown:
56
+ raise ValueError(f"unknown external project id(s): {', '.join(unknown)}")
57
+
58
+ workspace = Path(args.workspace).expanduser().resolve()
59
+ workspace.mkdir(parents=True, exist_ok=True)
60
+ fetched: list[dict[str, str]] = []
61
+ for case in cases:
62
+ if case["id"] not in selected:
63
+ continue
64
+ destination = safe_destination(workspace, str(case["local_path"]))
65
+ if destination.exists() or destination.is_symlink():
66
+ if not args.refresh:
67
+ raise RuntimeError(f"checkout already exists: {destination}; pass --refresh to replace it")
68
+ if destination.is_symlink() or not destination.is_dir():
69
+ raise RuntimeError(f"refusing to remove non-directory checkout path: {destination}")
70
+ shutil.rmtree(destination)
71
+ destination.parent.mkdir(parents=True, exist_ok=True)
72
+ source = str(case["source"])
73
+ run(["git", "clone", "--depth", str(args.depth), source, str(destination)])
74
+ commit = run(["git", "rev-parse", "HEAD"], cwd=destination)
75
+ fetched.append({"id": case["id"], "source": source, "path": str(destination), "commit": commit})
76
+
77
+ record = {
78
+ "schema_version": "1.0",
79
+ "manifest": str(manifest_path),
80
+ "fetched_at": now(),
81
+ "policy": "clone-only; no install, build, test or external code execution",
82
+ "projects": fetched,
83
+ }
84
+ record_path = workspace / ".motionloom-corpus-fetch.json"
85
+ record_path.write_text(json.dumps(record, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
86
+ print(json.dumps({"status": "pass", "workspace": str(workspace), "projects": fetched, "record": str(record_path)}, ensure_ascii=False))
87
+ return 0
88
+
89
+
90
+ if __name__ == "__main__":
91
+ raise SystemExit(main())
@@ -65,8 +65,30 @@ def validate(document: dict[str, Any], root: Path) -> dict[str, Any]:
65
65
  if key not in document:
66
66
  errors.append(issue("missing_field", f"required field is missing: {key}", key))
67
67
 
68
- if document.get("schema_version") != "0.1":
69
- errors.append(issue("schema_version", "frame generation lock schema_version must be 0.1", "schema_version"))
68
+ if document.get("schema_version") not in {"0.1", "0.2"}:
69
+ errors.append(issue("schema_version", "frame generation lock schema_version must be 0.1 or 0.2", "schema_version"))
70
+ enhanced = document.get("schema_version") == "0.2"
71
+ if enhanced:
72
+ if not ID_RE.match(str(document.get("sequence_id", ""))):
73
+ errors.append(issue("invalid_sequence_id", "schema_version 0.2 requires a safe sequence_id", "sequence_id"))
74
+ forbidden = document.get("forbidden_action_ids")
75
+ if not isinstance(forbidden, list) or not forbidden or any(not FRAME_RE.match(str(item)) for item in forbidden):
76
+ errors.append(issue("invalid_forbidden_actions", "schema_version 0.2 requires a non-empty safe forbidden_action_ids array", "forbidden_action_ids"))
77
+ elif str(document.get("action_id")) in {str(item) for item in forbidden}:
78
+ errors.append(issue("expected_action_forbidden", "action_id must not also be forbidden", "forbidden_action_ids"))
79
+ contract = document.get("action_contract") if isinstance(document.get("action_contract"), dict) else {}
80
+ action_manifest = str(document.get("action_manifest", ""))
81
+ if not action_manifest.lower().endswith(".json"):
82
+ errors.append(issue("invalid_action_manifest", "schema_version 0.2 requires a JSON action_manifest path", "action_manifest"))
83
+ else:
84
+ _, manifest_error = inside(root, action_manifest)
85
+ if manifest_error:
86
+ manifest_error["path"] = "action_manifest"
87
+ errors.append(manifest_error)
88
+ for key in ("positive_cues", "negative_cues"):
89
+ values = contract.get(key)
90
+ if not isinstance(values, list) or not values or any(not isinstance(item, str) or not item.strip() for item in values):
91
+ errors.append(issue("invalid_action_contract", f"action_contract.{key} must be a non-empty string array", f"action_contract.{key}"))
70
92
  if not ID_RE.match(str(document.get("lock_id", ""))):
71
93
  errors.append(issue("invalid_lock_id", "lock_id must use lowercase safe identifier characters", "lock_id"))
72
94
  if not FRAME_RE.match(str(document.get("action_id", ""))):
@@ -228,8 +250,13 @@ def compose_instruction(document: dict[str, Any], frame: dict[str, Any]) -> str:
228
250
  preserve = "; ".join(str(value).strip() for value in appearance["preserve"])
229
251
  forbid = "; ".join(str(value).strip() for value in appearance["forbid"])
230
252
  pixel_rule = " Use crisp nearest-neighbor pixel edges; do not resample or blur." if appearance.get("pixel_art", {}).get("enabled") else ""
253
+ sequence_clause = f" Sequence {document['sequence_id']} is immutable across this action." if document.get("sequence_id") else ""
254
+ contract = document.get("action_contract") if isinstance(document.get("action_contract"), dict) else {}
255
+ positive_clause = "; ".join(str(item).strip() for item in contract.get("positive_cues", []))
256
+ negative_clause = "; ".join(str(item).strip() for item in contract.get("negative_cues", []))
257
+ action_clause = f" Positive action cues: {positive_clause}. Negative action cues: {negative_clause}. Forbidden competing actions: {', '.join(str(item) for item in document.get('forbidden_action_ids', []))}." if positive_clause and negative_clause else ""
231
258
  return (
232
- f"MotionLoom Frame Generation Lock {document['lock_id']} for action {document['action_id']}. "
259
+ f"MotionLoom Frame Generation Lock {document['lock_id']} for action {document['action_id']}.{sequence_clause} "
233
260
  f"Use reference image {reference['image']} as the locked {reference['role']} with SHA-256 {reference['sha256']}. "
234
261
  f"Generate exactly ONE isolated source frame for {frame['frame_id']}; never create a pose sheet, contact sheet, collage, atlas, or multiple poses in one image. "
235
262
  f"Pose: {frame['pose']} "
@@ -238,7 +265,7 @@ def compose_instruction(document: dict[str, Any], frame: dict[str, Any]) -> str:
238
265
  f"Keep all opaque pixels inside safe rect x={safe['x']}, y={safe['y']}, width={safe['width']}, height={safe['height']} and preserve at least {geometry['min_padding_px']} px transparent padding. "
239
266
  f"Target apparent alpha bounds are approximately {target['width']} × {target['height']} px; do not introduce whole-subject zoom drift beyond ±{tolerances['bbox_width_px']} px width or ±{tolerances['bbox_height_px']} px height, pivot drift beyond ±{tolerances['pivot_px']} px, or footline drift beyond ±{tolerances['footline_px']} px. "
240
267
  f"Preserve: {preserve}. Forbid: {forbid}.{pixel_rule} "
241
- f"Do not mirror, crop from a shared canvas, silently change camera/scale, or resize the generated frame afterward. "
268
+ f"Do not mirror, crop from a shared canvas, silently change camera/scale, or resize the generated frame afterward.{action_clause} "
242
269
  f"Return/save only the single PNG as {frame['output']}. This is review evidence only; generation success does not imply artist authorship, production eligibility, runtime approval, licence, or user approval."
243
270
  )
244
271
 
@@ -267,15 +294,18 @@ def compose(document: dict[str, Any], root: Path, frame_id: str | None = None) -
267
294
  for frame in frames
268
295
  if isinstance(frame, dict)
269
296
  ]
297
+ manifest_flag = f" --action-manifest {document['action_manifest']}" if document.get("action_manifest") else ""
270
298
  return {
271
299
  "contract": "frame_generation_lock",
272
300
  "ready": True,
273
301
  "lock_id": document["lock_id"],
274
302
  "action_id": document["action_id"],
303
+ "sequence_id": document.get("sequence_id"),
304
+ "forbidden_action_ids": document.get("forbidden_action_ids", []),
275
305
  "lock_sha256": validation["metrics"]["lock_sha256"],
276
306
  "reference_sha256": validation["metrics"]["reference_sha256"],
277
307
  "frames": items,
278
- "next_gate": f"motionloom frame-set-preflight --input {postflight} --root {root} --json",
308
+ "next_gate": f"motionloom frame-set-preflight --input {postflight} --root {root}{manifest_flag} --json",
279
309
  "approval": False,
280
310
  }
281
311
 
@@ -21,6 +21,17 @@ from typing import Any
21
21
 
22
22
  ROOT = Path(__file__).resolve().parents[1]
23
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
24
35
 
25
36
 
26
37
  def load_asset_consistency():
@@ -34,6 +45,7 @@ def load_asset_consistency():
34
45
 
35
46
 
36
47
  AC = load_asset_consistency()
48
+ AS = load_action_separation()
37
49
 
38
50
 
39
51
  def _error(code: str, message: str, path: str = "") -> dict[str, str]:
@@ -66,7 +78,12 @@ def _shrink(rect: dict[str, int], margin: int) -> dict[str, int] | None:
66
78
  }
67
79
 
68
80
 
69
- def validate(document: dict[str, Any], root: Path, allow_shared_source: bool = False) -> dict[str, Any]:
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]:
70
87
  base = AC.validate_frame_geometry(document, root)
71
88
  errors = list(base.get("errors", []))
72
89
  warnings = list(base.get("warnings", []))
@@ -143,6 +160,12 @@ def validate(document: dict[str, Any], root: Path, allow_shared_source: bool = F
143
160
  )
144
161
  )
145
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
+
146
169
  remaining_warnings: list[dict[str, Any]] = []
147
170
  for item in warnings:
148
171
  if isinstance(item, dict) and item.get("code") == "bbox_drift":
@@ -162,6 +185,7 @@ def validate(document: dict[str, Any], root: Path, allow_shared_source: bool = F
162
185
  **base.get("metrics", {}),
163
186
  "isolated_source_required": not allow_shared_source,
164
187
  "unique_source_images": len(seen_images),
188
+ "action_manifest": action_result.get("metrics") if action_result else None,
165
189
  },
166
190
  "approval": False,
167
191
  }
@@ -172,6 +196,7 @@ def main(argv: list[str] | None = None) -> int:
172
196
  parser.add_argument("--input", required=True, help="frame-geometry contract JSON")
173
197
  parser.add_argument("--root", default=".", help="root used to resolve frame paths")
174
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")
175
200
  parser.add_argument("--json", action="store_true", dest="as_json")
176
201
  args = parser.parse_args(argv)
177
202
 
@@ -197,7 +222,32 @@ def main(argv: list[str] | None = None) -> int:
197
222
  "approval": False,
198
223
  }
199
224
  else:
200
- result = validate(document, Path(args.root).resolve(), args.allow_shared_source)
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)
201
251
 
202
252
  if args.as_json:
203
253
  print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -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