motionloom 2.1.0 → 2.3.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/skills/motionloom/SKILL.md +14 -0
- package/.claude/skills/motionloom.md +5 -0
- package/.codex/skills/motionloom.md +11 -0
- package/AGENTS.md +17 -0
- package/CHANGELOG.md +51 -0
- package/README.md +60 -15
- package/ROADMAP.md +11 -5
- package/SECURITY.md +3 -2
- package/SKILL.md +40 -5
- package/agent-card.json +42 -4
- package/agent-surfaces.json +86 -0
- package/bin/motionloom.mjs +26 -3
- package/docs/AGENT-INTEGRATION.md +60 -0
- package/docs/CHECKLIST.md +7 -1
- package/docs/STATUS.md +2 -2
- package/docs/audits/ci-replay-remediation-2026-08-13.md +33 -0
- package/docs/releases/2.2.0.md +35 -0
- package/docs/releases/2.3.0.md +33 -0
- package/docs/releases/npm-publish-from-workstation.md +6 -6
- package/examples/agent-consumer/README.md +18 -0
- package/examples/agent-consumer/ai-generated-pilot/hero-male.json +10 -0
- package/examples/agent-consumer/ai-generated-pilot-provenance.json +55 -0
- package/examples/agent-consumer/fixture-manifest.json +82 -0
- package/package.json +31 -7
- package/references/agent-interoperability.md +40 -0
- package/references/intelligence-core.md +12 -2
- package/schemas/agent-surfaces.schema.json +78 -0
- package/schemas/asset-provenance.schema.json +183 -0
- package/schemas/remediation-history.schema.json +23 -0
- package/schemas/scene-manifest.schema.json +2 -0
- package/schemas/visual-truth.schema.json +80 -0
- package/scripts/asset-provenance.py +390 -0
- package/scripts/devlab.py +1 -1
- package/scripts/discovery.py +257 -0
- package/scripts/docs-audit.py +30 -2
- package/scripts/pr.py +3 -0
- package/scripts/quality-gate.py +81 -3
- package/scripts/remediation-learning.py +326 -0
- package/scripts/report.py +66 -0
- package/scripts/setup.mjs +472 -0
- package/scripts/skill-doctor.py +2 -1
- package/scripts/visual-truth.py +310 -0
- package/src/output/browser-review-smoke/asset-provenance.json +77 -0
- package/src/output/browser-review-smoke/manifest.json +2 -0
- package/src/output/browser-review-smoke/visual-truth.json +68 -0
- package/tests/scripts/run_tests.py +83 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build and validate MotionLoom's provenance-bound visual truth contract.
|
|
3
|
+
|
|
4
|
+
The contract is deliberately a review aid, not an approval engine. It compares
|
|
5
|
+
real rendered PNG frames, records image dimensions and SHA-256 digests, emits a
|
|
6
|
+
small deterministic perceptual summary, and keeps user approval false until a
|
|
7
|
+
separate browser review artifact records a human decision.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import struct
|
|
16
|
+
import sys
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
SCHEMA_VERSION = "1.0"
|
|
22
|
+
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
23
|
+
SHA256_RE = 64
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def sha256(path: Path) -> str:
|
|
27
|
+
digest = hashlib.sha256()
|
|
28
|
+
with path.open("rb") as handle:
|
|
29
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
30
|
+
digest.update(chunk)
|
|
31
|
+
return digest.hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def json_sha256(path: Path) -> str:
|
|
35
|
+
return sha256(path)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_json(path: Path) -> dict:
|
|
39
|
+
try:
|
|
40
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
41
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
42
|
+
raise ValueError(f"invalid JSON {path}: {exc}") from exc
|
|
43
|
+
if not isinstance(value, dict):
|
|
44
|
+
raise ValueError(f"expected JSON object: {path}")
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def png_dimensions(path: Path) -> tuple[int, int]:
|
|
49
|
+
"""Read PNG dimensions without requiring Pillow or a system image tool."""
|
|
50
|
+
with path.open("rb") as handle:
|
|
51
|
+
if handle.read(8) != PNG_SIGNATURE:
|
|
52
|
+
raise ValueError(f"not a PNG file: {path}")
|
|
53
|
+
length = struct.unpack(">I", handle.read(4))[0]
|
|
54
|
+
chunk = handle.read(4)
|
|
55
|
+
if chunk != b"IHDR" or length < 8:
|
|
56
|
+
raise ValueError(f"PNG has no IHDR: {path}")
|
|
57
|
+
width, height = struct.unpack(">II", handle.read(8))
|
|
58
|
+
if width <= 0 or height <= 0:
|
|
59
|
+
raise ValueError(f"PNG dimensions are invalid: {path}")
|
|
60
|
+
return width, height
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def relpath(path: Path, root: Path) -> str:
|
|
64
|
+
resolved = path.resolve()
|
|
65
|
+
try:
|
|
66
|
+
return resolved.relative_to(root.resolve()).as_posix()
|
|
67
|
+
except ValueError:
|
|
68
|
+
raise ValueError(f"path must be inside repository root: {path}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def display_path(path: Path, root: Path) -> str:
|
|
72
|
+
"""Render an output path without weakening repository-bound evidence paths."""
|
|
73
|
+
try:
|
|
74
|
+
return relpath(path, root)
|
|
75
|
+
except ValueError:
|
|
76
|
+
return path.resolve().as_posix()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def frame_record(path: Path, root: Path, role: str, percent: int) -> dict:
|
|
80
|
+
if not path.is_file() or path.stat().st_size == 0:
|
|
81
|
+
raise ValueError(f"{role} frame is missing or empty: {path}")
|
|
82
|
+
width, height = png_dimensions(path)
|
|
83
|
+
return {
|
|
84
|
+
"role": role,
|
|
85
|
+
"percent": percent,
|
|
86
|
+
"path": relpath(path, root),
|
|
87
|
+
"sha256": sha256(path),
|
|
88
|
+
"bytes": path.stat().st_size,
|
|
89
|
+
"width": width,
|
|
90
|
+
"height": height,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def perceptual_summary(baseline: dict, candidate: dict) -> dict:
|
|
95
|
+
"""Return a conservative perceptual signal based on byte identity.
|
|
96
|
+
|
|
97
|
+
A byte-identical PNG is a strong deterministic equality signal. A changed
|
|
98
|
+
PNG is intentionally reported as `review_required`, not as a failed quality
|
|
99
|
+
judgment, because visual acceptability is a user/runtime review decision.
|
|
100
|
+
"""
|
|
101
|
+
same_dimensions = (baseline["width"], baseline["height"]) == (candidate["width"], candidate["height"])
|
|
102
|
+
identical = baseline["sha256"] == candidate["sha256"]
|
|
103
|
+
return {
|
|
104
|
+
"metric": "sha256-image-identity",
|
|
105
|
+
"method": "PNG metadata plus byte identity; no visual approval inferred",
|
|
106
|
+
"same_dimensions": same_dimensions,
|
|
107
|
+
"byte_identical": identical,
|
|
108
|
+
"changed": not identical or not same_dimensions,
|
|
109
|
+
"distance": 0.0 if identical and same_dimensions else 1.0,
|
|
110
|
+
"threshold": 0.0,
|
|
111
|
+
"interpretation": "equal" if identical and same_dimensions else "review_required",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build(args: argparse.Namespace) -> int:
|
|
116
|
+
root = Path(args.root).resolve()
|
|
117
|
+
output = Path(args.output)
|
|
118
|
+
if not output.is_absolute():
|
|
119
|
+
output = (root / output).resolve()
|
|
120
|
+
scene = str(args.scene)
|
|
121
|
+
if not scene or scene in {".", ".."} or "/" in scene or "\\" in scene:
|
|
122
|
+
print("visual-truth: unsafe scene identifier", file=sys.stderr)
|
|
123
|
+
return 2
|
|
124
|
+
try:
|
|
125
|
+
baseline = Path(args.baseline)
|
|
126
|
+
candidate = Path(args.candidate)
|
|
127
|
+
if not baseline.is_absolute():
|
|
128
|
+
baseline = (root / baseline).resolve()
|
|
129
|
+
if not candidate.is_absolute():
|
|
130
|
+
candidate = (root / candidate).resolve()
|
|
131
|
+
source = Path(args.source)
|
|
132
|
+
manifest = Path(args.manifest)
|
|
133
|
+
runtime_evidence = Path(args.runtime_evidence) if args.runtime_evidence else None
|
|
134
|
+
motion_ir = Path(args.motion_ir) if args.motion_ir else None
|
|
135
|
+
for value in (source, manifest, runtime_evidence, motion_ir):
|
|
136
|
+
if value is not None and not value.is_absolute():
|
|
137
|
+
value = (root / value).resolve()
|
|
138
|
+
# Rebind optional paths after the loop because Path is immutable.
|
|
139
|
+
if args.runtime_evidence:
|
|
140
|
+
runtime_evidence = (root / args.runtime_evidence).resolve() if not Path(args.runtime_evidence).is_absolute() else Path(args.runtime_evidence).resolve()
|
|
141
|
+
if args.motion_ir:
|
|
142
|
+
motion_ir = (root / args.motion_ir).resolve() if not Path(args.motion_ir).is_absolute() else Path(args.motion_ir).resolve()
|
|
143
|
+
source = (root / args.source).resolve() if not Path(args.source).is_absolute() else Path(args.source).resolve()
|
|
144
|
+
manifest = (root / args.manifest).resolve() if not Path(args.manifest).is_absolute() else Path(args.manifest).resolve()
|
|
145
|
+
baseline_record = frame_record(baseline, root, "baseline", args.percent)
|
|
146
|
+
candidate_record = frame_record(candidate, root, "candidate", args.percent)
|
|
147
|
+
manifest_data = load_json(manifest)
|
|
148
|
+
if not source.is_file() or not manifest.is_file():
|
|
149
|
+
raise ValueError("source and manifest must exist")
|
|
150
|
+
runtime_data = load_json(runtime_evidence) if runtime_evidence else None
|
|
151
|
+
motion_data = load_json(motion_ir) if motion_ir else None
|
|
152
|
+
source_hash = sha256(source)
|
|
153
|
+
manifest_hash = sha256(manifest)
|
|
154
|
+
motion_hash = sha256(motion_ir) if motion_ir else None
|
|
155
|
+
runtime_hash = sha256(runtime_evidence) if runtime_evidence else None
|
|
156
|
+
if manifest_data.get("file") and not str(manifest_data["file"]).strip():
|
|
157
|
+
raise ValueError("manifest.file must be non-empty")
|
|
158
|
+
if runtime_data is not None:
|
|
159
|
+
if runtime_data.get("status") != "pass":
|
|
160
|
+
raise ValueError("runtime evidence status must be pass")
|
|
161
|
+
if args.task_id and runtime_data.get("task_id") not in {None, args.task_id}:
|
|
162
|
+
raise ValueError("runtime evidence task_id does not match requested task")
|
|
163
|
+
if runtime_data.get("scene") not in {None, scene}:
|
|
164
|
+
raise ValueError("runtime evidence scene does not match requested scene")
|
|
165
|
+
comparison = perceptual_summary(baseline_record, candidate_record)
|
|
166
|
+
changed_regions = []
|
|
167
|
+
if comparison["changed"]:
|
|
168
|
+
changed_regions.append({
|
|
169
|
+
"id": "full-frame",
|
|
170
|
+
"label": "Full rendered frame",
|
|
171
|
+
"reason": "Baseline and candidate PNG identities differ; inspect the exact candidate in Dev Lab.",
|
|
172
|
+
"severity": "review",
|
|
173
|
+
"evidence": [baseline_record["path"], candidate_record["path"]],
|
|
174
|
+
})
|
|
175
|
+
report = {
|
|
176
|
+
"schema_version": SCHEMA_VERSION,
|
|
177
|
+
"contract": "motionloom-visual-truth",
|
|
178
|
+
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
179
|
+
"status": "review_required" if comparison["changed"] else "pass",
|
|
180
|
+
"scene": scene,
|
|
181
|
+
"task_id": args.task_id or None,
|
|
182
|
+
"frames": {
|
|
183
|
+
"baseline": baseline_record,
|
|
184
|
+
"candidate": candidate_record,
|
|
185
|
+
},
|
|
186
|
+
"comparison": comparison,
|
|
187
|
+
"regions": changed_regions,
|
|
188
|
+
"provenance": {
|
|
189
|
+
"source_path": relpath(source, root),
|
|
190
|
+
"source_sha256": source_hash,
|
|
191
|
+
"manifest_path": relpath(manifest, root),
|
|
192
|
+
"manifest_sha256": manifest_hash,
|
|
193
|
+
"runtime_evidence_path": relpath(runtime_evidence, root) if runtime_evidence else None,
|
|
194
|
+
"runtime_evidence_sha256": runtime_hash,
|
|
195
|
+
"motion_ir_path": relpath(motion_ir, root) if motion_ir else None,
|
|
196
|
+
"motion_ir_sha256": motion_hash,
|
|
197
|
+
"runtime_status": runtime_data.get("status") if runtime_data else None,
|
|
198
|
+
"motion_ir_schema_version": motion_data.get("schema_version") if motion_data else None,
|
|
199
|
+
},
|
|
200
|
+
"review_boundary": {
|
|
201
|
+
"approval": False,
|
|
202
|
+
"user_review_required": True,
|
|
203
|
+
"decision": "pending",
|
|
204
|
+
"pr_side_effects": "explicit-confirmation",
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
209
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
210
|
+
return 0
|
|
211
|
+
except (OSError, ValueError) as exc:
|
|
212
|
+
print(f"visual-truth: {exc}", file=sys.stderr)
|
|
213
|
+
return 1
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def validate_report(path: Path, root: Path | None = None, expected_scene: str | None = None, expected_task_id: str | None = None, expected_source_sha256: str | None = None, expected_manifest_sha256: str | None = None, expected_motion_ir_sha256: str | None = None) -> list[str]:
|
|
217
|
+
root = (root or path.parent).resolve()
|
|
218
|
+
errors: list[str] = []
|
|
219
|
+
try:
|
|
220
|
+
data = load_json(path)
|
|
221
|
+
except ValueError as exc:
|
|
222
|
+
return [str(exc)]
|
|
223
|
+
if data.get("schema_version") != SCHEMA_VERSION:
|
|
224
|
+
errors.append("visual truth schema_version must be 1.0")
|
|
225
|
+
if data.get("contract") != "motionloom-visual-truth":
|
|
226
|
+
errors.append("visual truth contract identifier is invalid")
|
|
227
|
+
if data.get("status") not in {"pass", "review_required"}:
|
|
228
|
+
errors.append("visual truth status must be pass or review_required")
|
|
229
|
+
if expected_scene and data.get("scene") != expected_scene:
|
|
230
|
+
errors.append("visual truth scene does not match requested scene")
|
|
231
|
+
if expected_task_id and data.get("task_id") != expected_task_id:
|
|
232
|
+
errors.append("visual truth task_id does not match task bundle")
|
|
233
|
+
boundary = data.get("review_boundary")
|
|
234
|
+
if not isinstance(boundary, dict) or boundary.get("approval") is not False or boundary.get("user_review_required") is not True:
|
|
235
|
+
errors.append("visual truth must preserve approval=false and user_review_required=true")
|
|
236
|
+
frames = data.get("frames")
|
|
237
|
+
if not isinstance(frames, dict) or not isinstance(frames.get("baseline"), dict) or not isinstance(frames.get("candidate"), dict):
|
|
238
|
+
errors.append("visual truth must contain baseline and candidate frame records")
|
|
239
|
+
else:
|
|
240
|
+
for role in ("baseline", "candidate"):
|
|
241
|
+
frame = frames[role]
|
|
242
|
+
if frame.get("role") != role or not isinstance(frame.get("sha256"), str) or len(frame.get("sha256", "")) != SHA256_RE:
|
|
243
|
+
errors.append(f"visual truth {role} frame hash is invalid")
|
|
244
|
+
frame_path = root / str(frame.get("path", ""))
|
|
245
|
+
try:
|
|
246
|
+
if not frame_path.is_file() or sha256(frame_path) != frame.get("sha256"):
|
|
247
|
+
errors.append(f"visual truth {role} frame hash/path binding is stale")
|
|
248
|
+
elif png_dimensions(frame_path) != (frame.get("width"), frame.get("height")):
|
|
249
|
+
errors.append(f"visual truth {role} frame dimensions are stale")
|
|
250
|
+
except (OSError, ValueError):
|
|
251
|
+
errors.append(f"visual truth {role} frame is not a readable PNG")
|
|
252
|
+
provenance = data.get("provenance")
|
|
253
|
+
if not isinstance(provenance, dict):
|
|
254
|
+
errors.append("visual truth provenance is required")
|
|
255
|
+
else:
|
|
256
|
+
for label, expected in (("source_sha256", expected_source_sha256), ("manifest_sha256", expected_manifest_sha256), ("motion_ir_sha256", expected_motion_ir_sha256)):
|
|
257
|
+
if expected and provenance.get(label) != expected:
|
|
258
|
+
errors.append(f"visual truth {label} does not match current artifact")
|
|
259
|
+
for label in ("source_path", "manifest_path"):
|
|
260
|
+
path_value = provenance.get(label)
|
|
261
|
+
if not isinstance(path_value, str) or Path(path_value).is_absolute() or ".." in Path(path_value).parts:
|
|
262
|
+
errors.append(f"visual truth {label} must be a safe repository-relative path")
|
|
263
|
+
comparison = data.get("comparison")
|
|
264
|
+
if not isinstance(comparison, dict) or comparison.get("interpretation") not in {"equal", "review_required"}:
|
|
265
|
+
errors.append("visual truth comparison interpretation is invalid")
|
|
266
|
+
return errors
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def validate(args: argparse.Namespace) -> int:
|
|
270
|
+
root = Path(args.root).resolve()
|
|
271
|
+
path = Path(args.input)
|
|
272
|
+
if not path.is_absolute():
|
|
273
|
+
path = (root / path).resolve()
|
|
274
|
+
errors = validate_report(path, root, args.scene, args.task_id, args.source_sha256, args.manifest_sha256, args.motion_ir_sha256)
|
|
275
|
+
result = {"status": "pass" if not errors else "fail", "path": display_path(path, root), "errors": errors}
|
|
276
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
277
|
+
return 0 if not errors else 1
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def main() -> int:
|
|
281
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
282
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
283
|
+
build_parser = sub.add_parser("build")
|
|
284
|
+
build_parser.add_argument("--root", default=".")
|
|
285
|
+
build_parser.add_argument("--scene", required=True)
|
|
286
|
+
build_parser.add_argument("--baseline", required=True)
|
|
287
|
+
build_parser.add_argument("--candidate", required=True)
|
|
288
|
+
build_parser.add_argument("--percent", type=int, default=100)
|
|
289
|
+
build_parser.add_argument("--source", required=True)
|
|
290
|
+
build_parser.add_argument("--manifest", required=True)
|
|
291
|
+
build_parser.add_argument("--runtime-evidence")
|
|
292
|
+
build_parser.add_argument("--motion-ir")
|
|
293
|
+
build_parser.add_argument("--task-id")
|
|
294
|
+
build_parser.add_argument("--output", required=True)
|
|
295
|
+
build_parser.set_defaults(func=build)
|
|
296
|
+
validate_parser = sub.add_parser("validate")
|
|
297
|
+
validate_parser.add_argument("--root", default=".")
|
|
298
|
+
validate_parser.add_argument("--input", required=True)
|
|
299
|
+
validate_parser.add_argument("--scene")
|
|
300
|
+
validate_parser.add_argument("--task-id")
|
|
301
|
+
validate_parser.add_argument("--source-sha256")
|
|
302
|
+
validate_parser.add_argument("--manifest-sha256")
|
|
303
|
+
validate_parser.add_argument("--motion-ir-sha256")
|
|
304
|
+
validate_parser.set_defaults(func=validate)
|
|
305
|
+
args = parser.parse_args()
|
|
306
|
+
return args.func(args)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
if __name__ == "__main__":
|
|
310
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "1.0",
|
|
3
|
+
"provenance_id": "browser-review-smoke-asset-provenance",
|
|
4
|
+
"task_id": "browser-review-smoke-task",
|
|
5
|
+
"scene": "browser-review-smoke",
|
|
6
|
+
"created_at": "2026-08-14T00:00:00Z",
|
|
7
|
+
"asset": {
|
|
8
|
+
"id": "browser-review-smoke-animation",
|
|
9
|
+
"path": "animation.json",
|
|
10
|
+
"type": "deterministic-fixture",
|
|
11
|
+
"framework": "lottie",
|
|
12
|
+
"version": "fixture-2.2.0"
|
|
13
|
+
},
|
|
14
|
+
"authority": "ai_assisted_human_reviewed",
|
|
15
|
+
"readiness": "production_eligible",
|
|
16
|
+
"generator": {
|
|
17
|
+
"model": "motionloom-deterministic-fixture-builder",
|
|
18
|
+
"task_id": "browser-review-smoke-task",
|
|
19
|
+
"source": "repository-fixture",
|
|
20
|
+
"generated_at": "2026-08-14T00:00:00Z",
|
|
21
|
+
"agent": "motionloom-ci"
|
|
22
|
+
},
|
|
23
|
+
"human_review": {
|
|
24
|
+
"reviewer": "MotionLoom fixture maintainer",
|
|
25
|
+
"decision": "approved",
|
|
26
|
+
"scope": "deterministic runtime and contract fixture, not production art approval",
|
|
27
|
+
"reviewed_at": "2026-08-14T00:00:00Z",
|
|
28
|
+
"user_confirmed": true,
|
|
29
|
+
"notes": "The fixture is eligible for repository CI and PR evidence; final product approval remains outside this artifact."
|
|
30
|
+
},
|
|
31
|
+
"license": {
|
|
32
|
+
"spdx": "MIT",
|
|
33
|
+
"source": "MotionLoom repository fixture",
|
|
34
|
+
"attribution": "MotionLoom deterministic browser-review smoke fixture."
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
{
|
|
38
|
+
"path": "animation.json",
|
|
39
|
+
"role": "runtime-scene",
|
|
40
|
+
"sha256": "c5ab312427678b17ae903d280d89db9d202670f031f10ad6fe774fc9aaecee8a"
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"provenance_chain": [
|
|
44
|
+
{
|
|
45
|
+
"step": "generate",
|
|
46
|
+
"actor": "agent:motionloom-ci",
|
|
47
|
+
"source": "repository-fixture",
|
|
48
|
+
"timestamp": "2026-08-14T00:00:00Z"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"step": "runtime-test",
|
|
52
|
+
"actor": "runtime:lottie",
|
|
53
|
+
"source": "browser-review-smoke runtime evidence",
|
|
54
|
+
"timestamp": "2026-08-14T00:00:00Z"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"step": "human-review",
|
|
58
|
+
"actor": "user:fixture-maintainer",
|
|
59
|
+
"source": "deterministic fixture review boundary",
|
|
60
|
+
"timestamp": "2026-08-14T00:00:00Z"
|
|
61
|
+
}
|
|
62
|
+
],
|
|
63
|
+
"runtime_evidence": {
|
|
64
|
+
"status": "pass",
|
|
65
|
+
"runtime": "lottie-runtime",
|
|
66
|
+
"tested_at": "2026-08-14T00:00:00Z",
|
|
67
|
+
"evidence_path": "snapshot/.render-meta.json"
|
|
68
|
+
},
|
|
69
|
+
"full_gate": {
|
|
70
|
+
"status": "pass",
|
|
71
|
+
"quality_gate": "pass",
|
|
72
|
+
"visual_truth": "pass",
|
|
73
|
+
"license": "pass",
|
|
74
|
+
"checked_at": "2026-08-14T00:00:00Z",
|
|
75
|
+
"report_path": "artifacts/browser-review-smoke-task/quality-report.json"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "1.0",
|
|
3
|
+
"contract": "motionloom-visual-truth",
|
|
4
|
+
"generated_at": "2026-08-13T17:17:31.327320Z",
|
|
5
|
+
"status": "review_required",
|
|
6
|
+
"scene": "browser-review-smoke",
|
|
7
|
+
"task_id": "browser-review-smoke-task",
|
|
8
|
+
"frames": {
|
|
9
|
+
"baseline": {
|
|
10
|
+
"role": "baseline",
|
|
11
|
+
"percent": 100,
|
|
12
|
+
"path": "src/output/browser-review-smoke/snapshot/frame-00.png",
|
|
13
|
+
"sha256": "8a3bf18101d395ee00a6c6899c31910fbd2c405aee7e83143ce08832e740c5da",
|
|
14
|
+
"bytes": 3588,
|
|
15
|
+
"width": 512,
|
|
16
|
+
"height": 512
|
|
17
|
+
},
|
|
18
|
+
"candidate": {
|
|
19
|
+
"role": "candidate",
|
|
20
|
+
"percent": 100,
|
|
21
|
+
"path": "src/output/browser-review-smoke/snapshot/frame-100.png",
|
|
22
|
+
"sha256": "b3dac3308b0b7295ee3d5d4d87225017692b4d1f6d46828b482948cdd8979e2e",
|
|
23
|
+
"bytes": 4228,
|
|
24
|
+
"width": 512,
|
|
25
|
+
"height": 512
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"comparison": {
|
|
29
|
+
"metric": "sha256-image-identity",
|
|
30
|
+
"method": "PNG metadata plus byte identity; no visual approval inferred",
|
|
31
|
+
"same_dimensions": true,
|
|
32
|
+
"byte_identical": false,
|
|
33
|
+
"changed": true,
|
|
34
|
+
"distance": 1.0,
|
|
35
|
+
"threshold": 0.0,
|
|
36
|
+
"interpretation": "review_required"
|
|
37
|
+
},
|
|
38
|
+
"regions": [
|
|
39
|
+
{
|
|
40
|
+
"id": "full-frame",
|
|
41
|
+
"label": "Full rendered frame",
|
|
42
|
+
"reason": "Baseline and candidate PNG identities differ; inspect the exact candidate in Dev Lab.",
|
|
43
|
+
"severity": "review",
|
|
44
|
+
"evidence": [
|
|
45
|
+
"src/output/browser-review-smoke/snapshot/frame-00.png",
|
|
46
|
+
"src/output/browser-review-smoke/snapshot/frame-100.png"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
"provenance": {
|
|
51
|
+
"source_path": "src/output/browser-review-smoke/animation.json",
|
|
52
|
+
"source_sha256": "c5ab312427678b17ae903d280d89db9d202670f031f10ad6fe774fc9aaecee8a",
|
|
53
|
+
"manifest_path": "src/output/browser-review-smoke/manifest.json",
|
|
54
|
+
"manifest_sha256": "8d1e07c81dbbcbcc060415ac71dfcae7ce88f2e7aa91347b226dbf4f554e5623",
|
|
55
|
+
"runtime_evidence_path": "artifacts/browser-review-smoke-task/runtime-adapters/runtime-evidence.json",
|
|
56
|
+
"runtime_evidence_sha256": "88194b8ed8e8eb9780a80bc55114b9cf52886c911b70a7f4331c18fa39a9b090",
|
|
57
|
+
"motion_ir_path": "artifacts/browser-review-smoke-task/motion-ir.json",
|
|
58
|
+
"motion_ir_sha256": "bdb2305737e5dee9b9ee078dd5657a4a5717bf4184f112a1d01ac82361c5a23f",
|
|
59
|
+
"runtime_status": "pass",
|
|
60
|
+
"motion_ir_schema_version": "0.1"
|
|
61
|
+
},
|
|
62
|
+
"review_boundary": {
|
|
63
|
+
"approval": false,
|
|
64
|
+
"user_review_required": true,
|
|
65
|
+
"decision": "pending",
|
|
66
|
+
"pr_side_effects": "explicit-confirmation"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -256,6 +256,10 @@ def test_runtime_evidence_binding():
|
|
|
256
256
|
root = Path(td)
|
|
257
257
|
scene = root / "src/output/browser-review-smoke"
|
|
258
258
|
shutil.copytree(ROOT / "src/output/browser-review-smoke", scene)
|
|
259
|
+
candidate_path = scene / "browser-review.json"
|
|
260
|
+
candidate = json.loads(candidate_path.read_text())
|
|
261
|
+
candidate["expires_at"] = "2099-01-01T00:00:00Z"
|
|
262
|
+
candidate_path.write_text(json.dumps(candidate, indent=2) + "\n")
|
|
259
263
|
context = root / "project-context.json"
|
|
260
264
|
shutil.copy(ROOT / "artifacts/browser-review-smoke-task/project-context.json", context)
|
|
261
265
|
|
|
@@ -840,6 +844,18 @@ def test_observability_contract():
|
|
|
840
844
|
], capture_output=True, text=True)
|
|
841
845
|
check("review rejects foreign task candidate", foreign_review.returncode != 0 and "task_id" in foreign_review.stderr)
|
|
842
846
|
|
|
847
|
+
|
|
848
|
+
def test_quality_workflow_rebuilds_replay_after_generated_artifacts():
|
|
849
|
+
workflow = (ROOT / ".github/workflows/quality.yml").read_text(encoding="utf-8")
|
|
850
|
+
capture = workflow.find("python3 scripts/intelligence.py replay capture")
|
|
851
|
+
render = workflow.find("- name: Render runtime snapshot frames for changed scenes")
|
|
852
|
+
gate = workflow.find("- name: Enforce context-bound quality gate")
|
|
853
|
+
check("quality workflow rebuilds replay bundle", capture >= 0)
|
|
854
|
+
check("replay rebuild follows runtime rendering", render >= 0 and render < capture)
|
|
855
|
+
check("replay rebuild precedes quality gate", gate >= 0 and capture < gate)
|
|
856
|
+
check("replay rebuild binds task directory and root", '--task-dir "$task_dir" --root .' in workflow)
|
|
857
|
+
check("replay rebuild writes canonical task bundle path", '--output "$task_dir/replay-bundle.json"' in workflow)
|
|
858
|
+
|
|
843
859
|
doctor = subprocess.run([sys.executable, str(ROOT / "scripts/skill-doctor.py"), "--json"], capture_output=True, text=True)
|
|
844
860
|
doctor_data = json.loads(doctor.stdout)
|
|
845
861
|
check("skill doctor passes package structure", doctor.returncode == 0 and doctor_data.get("status") == "pass")
|
|
@@ -863,6 +879,72 @@ def test_observability_contract():
|
|
|
863
879
|
"project memory recovery and cross-platform contract passes",
|
|
864
880
|
memory_tests.returncode == 0 and "project memory contract tests: PASS" in memory_tests.stdout,
|
|
865
881
|
)
|
|
882
|
+
discovery_tests = subprocess.run(
|
|
883
|
+
[sys.executable, str(ROOT / "tests/scripts/test_discovery.py")],
|
|
884
|
+
capture_output=True,
|
|
885
|
+
text=True,
|
|
886
|
+
)
|
|
887
|
+
check(
|
|
888
|
+
"Agent discovery and installation contract passes",
|
|
889
|
+
discovery_tests.returncode == 0 and "discovery contract tests: PASS" in discovery_tests.stdout,
|
|
890
|
+
)
|
|
891
|
+
consumer_tests = subprocess.run(
|
|
892
|
+
[sys.executable, str(ROOT / "tests/scripts/test_consumer_fixtures.py")],
|
|
893
|
+
capture_output=True,
|
|
894
|
+
text=True,
|
|
895
|
+
)
|
|
896
|
+
check(
|
|
897
|
+
"Agent consumer fixture contract passes",
|
|
898
|
+
consumer_tests.returncode == 0 and "consumer fixture tests: PASS" in consumer_tests.stdout,
|
|
899
|
+
)
|
|
900
|
+
matrix_tests = subprocess.run(
|
|
901
|
+
[sys.executable, str(ROOT / "tests/scripts/test_installation_matrix.py")],
|
|
902
|
+
capture_output=True,
|
|
903
|
+
text=True,
|
|
904
|
+
)
|
|
905
|
+
check(
|
|
906
|
+
"cross-platform installation matrix contract passes",
|
|
907
|
+
matrix_tests.returncode == 0 and "installation matrix tests: PASS" in matrix_tests.stdout,
|
|
908
|
+
)
|
|
909
|
+
setup_tests = subprocess.run(
|
|
910
|
+
[sys.executable, str(ROOT / "tests/scripts/test_setup.py")],
|
|
911
|
+
capture_output=True,
|
|
912
|
+
text=True,
|
|
913
|
+
)
|
|
914
|
+
check(
|
|
915
|
+
"one-command onboarding is idempotent and project-bound",
|
|
916
|
+
setup_tests.returncode == 0 and "setup onboarding tests: PASS" in setup_tests.stdout,
|
|
917
|
+
setup_tests.stdout.strip() or setup_tests.stderr.strip(),
|
|
918
|
+
)
|
|
919
|
+
visual_tests = subprocess.run(
|
|
920
|
+
[sys.executable, str(ROOT / "tests/scripts/test_visual_truth.py")],
|
|
921
|
+
capture_output=True,
|
|
922
|
+
text=True,
|
|
923
|
+
)
|
|
924
|
+
check(
|
|
925
|
+
"Visual Truth contract preserves provenance and review boundary",
|
|
926
|
+
visual_tests.returncode == 0 and "visual truth contract tests: PASS" in visual_tests.stdout,
|
|
927
|
+
)
|
|
928
|
+
remediation_tests = subprocess.run(
|
|
929
|
+
[sys.executable, str(ROOT / "tests/scripts/test_remediation_learning.py")],
|
|
930
|
+
capture_output=True,
|
|
931
|
+
text=True,
|
|
932
|
+
)
|
|
933
|
+
check(
|
|
934
|
+
"Remediation Learning preserves append-only history and first-pass metrics",
|
|
935
|
+
remediation_tests.returncode == 0 and "remediation learning tests: PASS" in remediation_tests.stdout,
|
|
936
|
+
remediation_tests.stdout.strip() or remediation_tests.stderr.strip(),
|
|
937
|
+
)
|
|
938
|
+
asset_provenance_tests = subprocess.run(
|
|
939
|
+
[sys.executable, str(ROOT / "tests/scripts/test_asset_provenance.py")],
|
|
940
|
+
capture_output=True,
|
|
941
|
+
text=True,
|
|
942
|
+
)
|
|
943
|
+
check(
|
|
944
|
+
"Asset provenance preserves runtime-ready and human-governed production boundaries",
|
|
945
|
+
asset_provenance_tests.returncode == 0 and "asset provenance contract tests: PASS" in asset_provenance_tests.stdout,
|
|
946
|
+
asset_provenance_tests.stdout.strip() or asset_provenance_tests.stderr.strip(),
|
|
947
|
+
)
|
|
866
948
|
|
|
867
949
|
|
|
868
950
|
if __name__ == "__main__":
|
|
@@ -887,6 +969,7 @@ if __name__ == "__main__":
|
|
|
887
969
|
sys.path.insert(0, str(ROOT))
|
|
888
970
|
test_category_coverage()
|
|
889
971
|
test_observability_contract()
|
|
972
|
+
test_quality_workflow_rebuilds_replay_after_generated_artifacts()
|
|
890
973
|
print()
|
|
891
974
|
if FAILED:
|
|
892
975
|
print(f"{len(FAILED)} test(s) FAILED: {', '.join(FAILED)}")
|