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.
- package/AGENTS.md +3 -1
- package/CHANGELOG.md +68 -0
- package/CONTRIBUTING.md +3 -1
- package/README.md +20 -4
- package/SECURITY.md +5 -5
- package/SKILL.md +15 -5
- package/agent-card.json +77 -14
- package/agent-surfaces.json +30 -7
- package/artifact-adapter-registry.json +568 -20
- package/bin/motionloom.mjs +25 -2
- 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.6.1.md +57 -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 +97 -0
- package/package.json +23 -9
- package/references/multi-frame-asset-generation.md +144 -0
- 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 +189 -0
- 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 +358 -0
- package/scripts/frame-set-preflight.py +264 -0
- 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_consistency.py +77 -4
- package/tests/scripts/test_asset_generation_plan.py +250 -0
- 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 {
|
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
|
|
package/scripts/review-hook.py
CHANGED
|
@@ -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
|
-
|
|
338
|
-
|
|
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:
|
package/scripts/skill-doctor.py
CHANGED
|
@@ -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:
|