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.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +42 -0
- package/CONTRIBUTING.md +3 -1
- package/README.md +19 -3
- package/SECURITY.md +5 -5
- package/SKILL.md +6 -3
- package/agent-card.json +18 -4
- package/agent-surfaces.json +1 -1
- package/artifact-adapter-registry.json +568 -20
- package/bin/motionloom.mjs +21 -4
- package/capability-registry.json +58 -234
- package/dev-lab/public/devlab.js +36 -3
- package/dev-lab/public/index.html +6 -2
- package/docs/ACTION-SEPARATION.md +104 -0
- package/docs/ASSET-GENERATION-PLANNER.md +101 -0
- package/docs/BRANCH-PROTECTION.md +44 -0
- package/docs/EXTERNAL-CORPUS.md +26 -0
- package/docs/STATUS.md +3 -2
- package/docs/audits/field-test-after-hardening-2026-08-21.md +25 -0
- package/docs/releases/2.7.0.md +98 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.00.json +32 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.01.json +32 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.02.json +32 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.03.json +32 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/hero-walk-action-manifest.json +81 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.00.json +26 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.01.json +26 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.02.json +26 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.03.json +26 -0
- package/examples/agent-consumer/asset-planning/pixellab-hero-256x448-request.json +43 -0
- package/examples/agent-consumer/devlab-live-sprite/README.md +30 -0
- package/examples/agent-consumer/devlab-live-sprite/devlab-runtime.json +61 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/idle-00.png +0 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/idle-01.png +0 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/idle-02.png +0 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/reverse-00.png +0 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/reverse-01.png +0 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/reverse-02.png +0 -0
- package/examples/agent-consumer/frame-generation-lock/hero-walk-lock.json +14 -1
- package/package.json +15 -1
- package/references/multi-frame-asset-generation.md +37 -2
- package/schemas/action-separation-verifier-evidence.schema.json +43 -0
- package/schemas/action-sequence-manifest.schema.json +48 -0
- package/schemas/artifact-adapter-registry.schema.json +1 -1
- package/schemas/asset-adaptation.schema.json +21 -0
- package/schemas/asset-generation-plan.schema.json +77 -0
- package/schemas/asset-generation-request.schema.json +104 -0
- package/schemas/frame-envelope.schema.json +62 -0
- package/schemas/frame-generation-lock.schema.json +24 -1
- package/scripts/action-separation.py +410 -0
- package/scripts/asset-adapt.mjs +92 -0
- package/scripts/asset-generation-plan.py +613 -0
- package/scripts/browser_review_consistency.py +64 -0
- package/scripts/fetch-project-corpus.py +91 -0
- package/scripts/frame-generation-lock.py +35 -5
- package/scripts/frame-set-preflight.py +52 -2
- package/scripts/package-consumer-smoke.mjs +20 -0
- package/scripts/quality-gate.py +7 -0
- package/scripts/release-verify.py +25 -0
- package/scripts/report-contract.py +1 -1
- package/scripts/report.py +19 -4
- package/scripts/resolve-task-bundle.py +11 -3
- package/scripts/review-hook.py +51 -2
- package/scripts/skill-doctor.py +42 -0
- package/src/output/browser-review-smoke/browser-review.json +6 -6
- package/tests/scripts/run_tests.py +45 -2
- package/tests/scripts/test_asset_adapt.py +47 -0
- package/tests/scripts/test_asset_generation_plan.py +250 -0
- 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")
|
|
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(
|
|
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
|
-
|
|
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 {
|
package/scripts/quality-gate.py
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|