motionloom 2.2.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/CHANGELOG.md +25 -1
- package/README.md +54 -15
- package/ROADMAP.md +5 -3
- package/SKILL.md +15 -3
- package/agent-card.json +21 -4
- package/agent-surfaces.json +8 -1
- package/bin/motionloom.mjs +15 -2
- package/docs/AGENT-INTEGRATION.md +15 -2
- package/docs/CHECKLIST.md +5 -0
- package/docs/STATUS.md +2 -2
- package/docs/releases/2.3.0.md +33 -0
- package/docs/releases/npm-publish-from-workstation.md +6 -6
- package/examples/agent-consumer/ai-generated-pilot/hero-male.json +10 -0
- package/examples/agent-consumer/ai-generated-pilot-provenance.json +55 -0
- package/package.json +11 -2
- package/references/agent-interoperability.md +11 -0
- package/references/intelligence-core.md +8 -2
- package/schemas/agent-surfaces.schema.json +1 -1
- package/schemas/asset-provenance.schema.json +183 -0
- package/schemas/scene-manifest.schema.json +1 -0
- package/scripts/asset-provenance.py +390 -0
- package/scripts/docs-audit.py +14 -2
- package/scripts/pr.py +2 -0
- package/scripts/quality-gate.py +41 -3
- package/scripts/report.py +50 -0
- package/scripts/setup.mjs +472 -0
- package/scripts/skill-doctor.py +2 -1
- package/src/output/browser-review-smoke/asset-provenance.json +77 -0
- package/src/output/browser-review-smoke/manifest.json +1 -0
- package/src/output/browser-review-smoke/visual-truth.json +3 -3
- package/tests/scripts/run_tests.py +24 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Validate and classify asset-level provenance without granting approval.
|
|
2
|
+
|
|
3
|
+
The contract is intentionally fail-closed. An Agent may declare how an asset
|
|
4
|
+
was produced and may prove runtime readiness, but it cannot manufacture human
|
|
5
|
+
authority or production approval. The script uses pathlib and JSON only so
|
|
6
|
+
the same entrypoint works on Ubuntu, macOS and Windows.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
22
|
+
SCHEMA_VERSION = "1.0"
|
|
23
|
+
AUTHORITIES = {
|
|
24
|
+
"ai_generated",
|
|
25
|
+
"ai_assisted",
|
|
26
|
+
"ai_assisted_human_reviewed",
|
|
27
|
+
"artist_authored",
|
|
28
|
+
"unknown",
|
|
29
|
+
}
|
|
30
|
+
READINESS = {
|
|
31
|
+
"blocked",
|
|
32
|
+
"runtime_ready",
|
|
33
|
+
"review_required",
|
|
34
|
+
"production_eligible",
|
|
35
|
+
"production_approved",
|
|
36
|
+
}
|
|
37
|
+
SAFE_RELATIVE = re.compile(r"^(?!/)(?!.*(?:^|/)\.\.(?:/|$)).+")
|
|
38
|
+
SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_json(path: Path) -> dict[str, Any]:
|
|
42
|
+
try:
|
|
43
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
44
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
45
|
+
raise ValueError(f"{path}: {exc}") from exc
|
|
46
|
+
if not isinstance(value, dict):
|
|
47
|
+
raise ValueError(f"{path}: provenance document must be a JSON object")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_datetime(value: Any) -> bool:
|
|
52
|
+
if not isinstance(value, str) or not value.strip():
|
|
53
|
+
return False
|
|
54
|
+
try:
|
|
55
|
+
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
56
|
+
except ValueError:
|
|
57
|
+
return False
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_relative_path(value: Any) -> bool:
|
|
62
|
+
return isinstance(value, str) and bool(SAFE_RELATIVE.fullmatch(value))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_sha256(value: Any) -> bool:
|
|
66
|
+
return isinstance(value, str) and bool(SHA256.fullmatch(value))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _required(data: dict[str, Any], keys: tuple[str, ...], prefix: str, errors: list[str]) -> None:
|
|
70
|
+
for key in keys:
|
|
71
|
+
if key not in data:
|
|
72
|
+
errors.append(f"{prefix}.{key} is required")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _check_text(data: dict[str, Any], key: str, prefix: str, errors: list[str]) -> None:
|
|
76
|
+
if key in data and (not isinstance(data[key], str) or not data[key].strip()):
|
|
77
|
+
errors.append(f"{prefix}.{key} must be a non-empty string")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _check_human_review(data: dict[str, Any], errors: list[str]) -> None:
|
|
81
|
+
review = data.get("human_review")
|
|
82
|
+
if not isinstance(review, dict):
|
|
83
|
+
errors.append("human_review is required for human-reviewed readiness")
|
|
84
|
+
return
|
|
85
|
+
_required(review, ("reviewer", "decision", "scope", "reviewed_at", "user_confirmed"), "human_review", errors)
|
|
86
|
+
_check_text(review, "reviewer", "human_review", errors)
|
|
87
|
+
if review.get("decision") not in {"approved", "rejected", "changes_requested"}:
|
|
88
|
+
errors.append("human_review.decision must be approved, rejected or changes_requested")
|
|
89
|
+
if not is_datetime(review.get("reviewed_at")):
|
|
90
|
+
errors.append("human_review.reviewed_at must be an ISO-8601 timestamp")
|
|
91
|
+
if review.get("user_confirmed") is not True:
|
|
92
|
+
errors.append("human_review.user_confirmed must be true; Agent-only review is not sufficient")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _check_human_attestation(data: dict[str, Any], errors: list[str]) -> None:
|
|
96
|
+
attestation = data.get("human_attestation")
|
|
97
|
+
if not isinstance(attestation, dict):
|
|
98
|
+
errors.append("artist_authored requires human_attestation")
|
|
99
|
+
return
|
|
100
|
+
_required(attestation, ("attestor", "attestor_type", "decision", "attested_at", "user_confirmed"), "human_attestation", errors)
|
|
101
|
+
_check_text(attestation, "attestor", "human_attestation", errors)
|
|
102
|
+
if attestation.get("attestor_type") not in {"artist", "user"}:
|
|
103
|
+
errors.append("human_attestation.attestor_type must be artist or user")
|
|
104
|
+
if attestation.get("decision") != "artist_authored":
|
|
105
|
+
errors.append("human_attestation.decision must be artist_authored")
|
|
106
|
+
if not is_datetime(attestation.get("attested_at")):
|
|
107
|
+
errors.append("human_attestation.attested_at must be an ISO-8601 timestamp")
|
|
108
|
+
if attestation.get("user_confirmed") is not True:
|
|
109
|
+
errors.append("human_attestation.user_confirmed must be true; Agent-only authority is rejected")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _check_generator(data: dict[str, Any], errors: list[str]) -> None:
|
|
113
|
+
generator = data.get("generator")
|
|
114
|
+
if not isinstance(generator, dict):
|
|
115
|
+
errors.append("AI-origin authority requires generator metadata")
|
|
116
|
+
return
|
|
117
|
+
_required(generator, ("model", "task_id", "source", "generated_at"), "generator", errors)
|
|
118
|
+
for key in ("model", "task_id", "source"):
|
|
119
|
+
_check_text(generator, key, "generator", errors)
|
|
120
|
+
if not is_datetime(generator.get("generated_at")):
|
|
121
|
+
errors.append("generator.generated_at must be an ISO-8601 timestamp")
|
|
122
|
+
if "prompt_hash" in generator and not is_sha256(generator.get("prompt_hash")):
|
|
123
|
+
errors.append("generator.prompt_hash must be a lowercase SHA-256")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _check_files(data: dict[str, Any], errors: list[str]) -> None:
|
|
127
|
+
files = data.get("files")
|
|
128
|
+
if not isinstance(files, list) or not files:
|
|
129
|
+
errors.append("files must contain at least one asset file")
|
|
130
|
+
return
|
|
131
|
+
for index, item in enumerate(files):
|
|
132
|
+
prefix = f"files[{index}]"
|
|
133
|
+
if not isinstance(item, dict):
|
|
134
|
+
errors.append(f"{prefix} must be an object")
|
|
135
|
+
continue
|
|
136
|
+
_required(item, ("path", "role", "sha256"), prefix, errors)
|
|
137
|
+
if not is_relative_path(item.get("path")):
|
|
138
|
+
errors.append(f"{prefix}.path must be a safe relative path")
|
|
139
|
+
_check_text(item, "role", prefix, errors)
|
|
140
|
+
if not is_sha256(item.get("sha256")):
|
|
141
|
+
errors.append(f"{prefix}.sha256 must be a lowercase SHA-256")
|
|
142
|
+
if "bytes" in item and (not isinstance(item["bytes"], int) or item["bytes"] < 0):
|
|
143
|
+
errors.append(f"{prefix}.bytes must be a non-negative integer")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _check_chain(data: dict[str, Any], errors: list[str]) -> None:
|
|
147
|
+
chain = data.get("provenance_chain")
|
|
148
|
+
if not isinstance(chain, list) or not chain:
|
|
149
|
+
errors.append("provenance_chain must contain at least one step")
|
|
150
|
+
return
|
|
151
|
+
for index, item in enumerate(chain):
|
|
152
|
+
prefix = f"provenance_chain[{index}]"
|
|
153
|
+
if not isinstance(item, dict):
|
|
154
|
+
errors.append(f"{prefix} must be an object")
|
|
155
|
+
continue
|
|
156
|
+
_required(item, ("step", "actor", "source", "timestamp"), prefix, errors)
|
|
157
|
+
for key in ("step", "actor", "source"):
|
|
158
|
+
_check_text(item, key, prefix, errors)
|
|
159
|
+
if not is_datetime(item.get("timestamp")):
|
|
160
|
+
errors.append(f"{prefix}.timestamp must be an ISO-8601 timestamp")
|
|
161
|
+
if "sha256" in item and not is_sha256(item.get("sha256")):
|
|
162
|
+
errors.append(f"{prefix}.sha256 must be a lowercase SHA-256")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def validate_document(data: dict[str, Any]) -> list[str]:
|
|
166
|
+
errors: list[str] = []
|
|
167
|
+
_required(data, ("schema_version", "provenance_id", "asset", "authority", "readiness", "files", "license", "provenance_chain", "created_at"), "provenance", errors)
|
|
168
|
+
if data.get("schema_version") != SCHEMA_VERSION:
|
|
169
|
+
errors.append(f"schema_version must be {SCHEMA_VERSION}")
|
|
170
|
+
_check_text(data, "provenance_id", "provenance", errors)
|
|
171
|
+
if not is_datetime(data.get("created_at")):
|
|
172
|
+
errors.append("created_at must be an ISO-8601 timestamp")
|
|
173
|
+
|
|
174
|
+
asset = data.get("asset")
|
|
175
|
+
if not isinstance(asset, dict):
|
|
176
|
+
errors.append("asset must be an object")
|
|
177
|
+
else:
|
|
178
|
+
_required(asset, ("id", "path", "type", "framework"), "asset", errors)
|
|
179
|
+
_check_text(asset, "id", "asset", errors)
|
|
180
|
+
if not is_relative_path(asset.get("path")):
|
|
181
|
+
errors.append("asset.path must be a safe relative path")
|
|
182
|
+
for key in ("type", "framework"):
|
|
183
|
+
_check_text(asset, key, "asset", errors)
|
|
184
|
+
|
|
185
|
+
authority = data.get("authority")
|
|
186
|
+
readiness = data.get("readiness")
|
|
187
|
+
if authority not in AUTHORITIES:
|
|
188
|
+
errors.append(f"authority must be one of {sorted(AUTHORITIES)}")
|
|
189
|
+
if readiness not in READINESS:
|
|
190
|
+
errors.append(f"readiness must be one of {sorted(READINESS)}")
|
|
191
|
+
|
|
192
|
+
license_data = data.get("license")
|
|
193
|
+
if not isinstance(license_data, dict):
|
|
194
|
+
errors.append("license must be an object")
|
|
195
|
+
else:
|
|
196
|
+
_required(license_data, ("spdx", "source", "attribution"), "license", errors)
|
|
197
|
+
for key in ("spdx", "source", "attribution"):
|
|
198
|
+
_check_text(license_data, key, "license", errors)
|
|
199
|
+
|
|
200
|
+
_check_files(data, errors)
|
|
201
|
+
_check_chain(data, errors)
|
|
202
|
+
if authority in {"ai_generated", "ai_assisted", "ai_assisted_human_reviewed"}:
|
|
203
|
+
_check_generator(data, errors)
|
|
204
|
+
if authority == "artist_authored":
|
|
205
|
+
_check_human_attestation(data, errors)
|
|
206
|
+
|
|
207
|
+
if authority == "unknown" and readiness != "blocked":
|
|
208
|
+
errors.append("unknown authority is always blocked")
|
|
209
|
+
if authority == "ai_generated" and readiness != "runtime_ready":
|
|
210
|
+
errors.append("ai_generated assets may be runtime_ready only; they are never production eligible")
|
|
211
|
+
if authority == "ai_assisted_human_reviewed" and readiness not in {"review_required", "production_eligible"}:
|
|
212
|
+
errors.append("ai_assisted_human_reviewed assets must remain review_required or pass a later production gate")
|
|
213
|
+
if authority == "ai_assisted_human_reviewed":
|
|
214
|
+
_check_human_review(data, errors)
|
|
215
|
+
if authority == "ai_assisted" and readiness not in {"runtime_ready", "review_required", "production_eligible"}:
|
|
216
|
+
errors.append("ai_assisted assets must be runtime_ready, review_required or production_eligible")
|
|
217
|
+
if authority == "ai_assisted" and readiness == "production_eligible":
|
|
218
|
+
_check_human_review(data, errors)
|
|
219
|
+
if readiness in {"production_eligible", "production_approved"}:
|
|
220
|
+
runtime = data.get("runtime_evidence")
|
|
221
|
+
if not isinstance(runtime, dict) or runtime.get("status") != "pass":
|
|
222
|
+
errors.append("production eligibility requires runtime_evidence.status=pass")
|
|
223
|
+
full_gate = data.get("full_gate")
|
|
224
|
+
if not isinstance(full_gate, dict):
|
|
225
|
+
errors.append("production eligibility requires full_gate evidence")
|
|
226
|
+
else:
|
|
227
|
+
_required(full_gate, ("status", "quality_gate", "visual_truth", "license", "checked_at"), "full_gate", errors)
|
|
228
|
+
if full_gate.get("status") != "pass" or full_gate.get("quality_gate") != "pass" or full_gate.get("visual_truth") != "pass" or full_gate.get("license") != "pass":
|
|
229
|
+
errors.append("full_gate must have pass for status, quality_gate, visual_truth and license")
|
|
230
|
+
if not is_datetime(full_gate.get("checked_at")):
|
|
231
|
+
errors.append("full_gate.checked_at must be an ISO-8601 timestamp")
|
|
232
|
+
if authority in {"ai_assisted", "ai_assisted_human_reviewed"}:
|
|
233
|
+
_check_human_review(data, errors)
|
|
234
|
+
if authority in {"unknown", "ai_generated"}:
|
|
235
|
+
errors.append(f"{authority} cannot be production eligible")
|
|
236
|
+
if readiness == "production_approved":
|
|
237
|
+
approval = data.get("human_approval")
|
|
238
|
+
if not isinstance(approval, dict):
|
|
239
|
+
errors.append("production_approved requires human_approval")
|
|
240
|
+
else:
|
|
241
|
+
_required(approval, ("issued_by", "decision", "approved_at", "user_confirmed"), "human_approval", errors)
|
|
242
|
+
issuer = approval.get("issued_by")
|
|
243
|
+
if not isinstance(issuer, dict) or issuer.get("type") not in {"user", "artist"} or not str(issuer.get("id") or "").strip():
|
|
244
|
+
errors.append("human_approval.issued_by must be a non-empty user or artist identity")
|
|
245
|
+
if approval.get("decision") != "approved":
|
|
246
|
+
errors.append("human_approval.decision must be approved")
|
|
247
|
+
if not is_datetime(approval.get("approved_at")):
|
|
248
|
+
errors.append("human_approval.approved_at must be an ISO-8601 timestamp")
|
|
249
|
+
if approval.get("user_confirmed") is not True:
|
|
250
|
+
errors.append("human_approval.user_confirmed must be true")
|
|
251
|
+
if authority in {"unknown", "ai_generated"}:
|
|
252
|
+
errors.append("unknown and ai_generated assets can never be production_approved")
|
|
253
|
+
if "human_approval" in data and readiness != "production_approved":
|
|
254
|
+
errors.append("human_approval is only valid when readiness is production_approved")
|
|
255
|
+
return errors
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _resolve_inside(base: Path, relative: str) -> Path | None:
|
|
259
|
+
if not is_relative_path(relative):
|
|
260
|
+
return None
|
|
261
|
+
resolved = (base / relative).resolve()
|
|
262
|
+
try:
|
|
263
|
+
resolved.relative_to(base.resolve())
|
|
264
|
+
except ValueError:
|
|
265
|
+
return None
|
|
266
|
+
return resolved
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def validate_files(data: dict[str, Any], base: Path) -> list[str]:
|
|
270
|
+
errors: list[str] = []
|
|
271
|
+
for item in data.get("files", []):
|
|
272
|
+
path = _resolve_inside(base, str(item.get("path", "")))
|
|
273
|
+
if path is None or not path.is_file():
|
|
274
|
+
errors.append(f"asset file is missing: {item.get('path', '<unknown>')}")
|
|
275
|
+
continue
|
|
276
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
277
|
+
if digest != item.get("sha256"):
|
|
278
|
+
errors.append(f"asset file SHA-256 mismatch: {item.get('path', '<unknown>')}")
|
|
279
|
+
if "bytes" in item and path.stat().st_size != item.get("bytes"):
|
|
280
|
+
errors.append(f"asset file byte count mismatch: {item.get('path', '<unknown>')}")
|
|
281
|
+
asset_path = _resolve_inside(base, str((data.get("asset") or {}).get("path", "")))
|
|
282
|
+
if asset_path is None or not asset_path.is_file():
|
|
283
|
+
errors.append(f"asset.path is missing: {(data.get('asset') or {}).get('path', '<unknown>')}")
|
|
284
|
+
return errors
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def classify(data: dict[str, Any], errors: list[str] | None = None) -> dict[str, Any]:
|
|
288
|
+
errors = errors or []
|
|
289
|
+
authority = data.get("authority", "unknown")
|
|
290
|
+
readiness = data.get("readiness", "blocked")
|
|
291
|
+
effective = "blocked" if errors or authority == "unknown" else "runtime_ready"
|
|
292
|
+
if not errors:
|
|
293
|
+
if authority == "ai_generated":
|
|
294
|
+
effective = "runtime_ready"
|
|
295
|
+
elif authority in {"ai_assisted", "ai_assisted_human_reviewed"}:
|
|
296
|
+
review = data.get("human_review") or {}
|
|
297
|
+
if data.get("full_gate", {}).get("status") == "pass" and review.get("decision") == "approved" and review.get("user_confirmed") is True:
|
|
298
|
+
effective = "production_eligible"
|
|
299
|
+
elif authority == "ai_assisted_human_reviewed" or readiness == "review_required":
|
|
300
|
+
effective = "review_required"
|
|
301
|
+
else:
|
|
302
|
+
effective = "runtime_ready"
|
|
303
|
+
elif authority == "artist_authored":
|
|
304
|
+
effective = "production_eligible" if data.get("full_gate", {}).get("status") == "pass" else "runtime_ready"
|
|
305
|
+
if readiness == "production_approved" and data.get("human_approval", {}).get("user_confirmed") is True:
|
|
306
|
+
effective = "production_approved"
|
|
307
|
+
return {
|
|
308
|
+
"authority": authority,
|
|
309
|
+
"declared_readiness": readiness,
|
|
310
|
+
"effective_readiness": effective,
|
|
311
|
+
"production_eligible": effective in {"production_eligible", "production_approved"},
|
|
312
|
+
"production_approved": effective == "production_approved",
|
|
313
|
+
"errors": errors,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def evaluate(path: Path, *, base: Path | None = None, mode: str = "contract", manifest: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
318
|
+
try:
|
|
319
|
+
data = read_json(path)
|
|
320
|
+
except ValueError as exc:
|
|
321
|
+
return {"status": "fail", "provenance_path": str(path), "errors": [str(exc)]}
|
|
322
|
+
errors = validate_document(data)
|
|
323
|
+
if manifest is not None:
|
|
324
|
+
manifest_file = manifest.get("file")
|
|
325
|
+
asset_path = (data.get("asset") or {}).get("path")
|
|
326
|
+
if manifest_file and asset_path != manifest_file:
|
|
327
|
+
errors.append("asset.path must match scene manifest.file")
|
|
328
|
+
provenance_ref = manifest.get("asset_provenance")
|
|
329
|
+
if provenance_ref and provenance_ref != path.name and provenance_ref != path.as_posix():
|
|
330
|
+
errors.append("scene manifest asset_provenance does not match the loaded provenance file")
|
|
331
|
+
if mode in {"runtime", "production"}:
|
|
332
|
+
if base is None:
|
|
333
|
+
errors.append(f"{mode} check requires --root for asset file hash verification")
|
|
334
|
+
else:
|
|
335
|
+
errors.extend(validate_files(data, base))
|
|
336
|
+
runtime = data.get("runtime_evidence") or {}
|
|
337
|
+
if runtime.get("status") != "pass":
|
|
338
|
+
errors.append("runtime check requires runtime_evidence.status=pass")
|
|
339
|
+
if mode == "production":
|
|
340
|
+
summary = classify(data, errors)
|
|
341
|
+
if summary["effective_readiness"] != "production_eligible":
|
|
342
|
+
errors.append("production check requires effective_readiness=production_eligible; production_approved remains a human-only state")
|
|
343
|
+
summary = classify(data, errors)
|
|
344
|
+
return {
|
|
345
|
+
"status": "pass" if not errors else "fail",
|
|
346
|
+
"mode": mode,
|
|
347
|
+
"provenance_path": str(path),
|
|
348
|
+
"summary": summary,
|
|
349
|
+
"errors": errors,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def print_result(result: dict[str, Any], as_json: bool = True) -> None:
|
|
354
|
+
if as_json:
|
|
355
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
356
|
+
return
|
|
357
|
+
print(f"Asset provenance: {result.get('status', 'fail').upper()}")
|
|
358
|
+
summary = result.get("summary") or {}
|
|
359
|
+
print(f"Authority: {summary.get('authority', 'unknown')}")
|
|
360
|
+
print(f"Declared readiness: {summary.get('declared_readiness', 'blocked')}")
|
|
361
|
+
print(f"Effective readiness: {summary.get('effective_readiness', 'blocked')}")
|
|
362
|
+
for error in result.get("errors", []):
|
|
363
|
+
print(f"- {error}")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def main() -> int:
|
|
367
|
+
parser = argparse.ArgumentParser(description="Validate, classify and report MotionLoom asset provenance")
|
|
368
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
369
|
+
for name in ("validate", "classify", "check", "report"):
|
|
370
|
+
command = sub.add_parser(name)
|
|
371
|
+
command.add_argument("--input", required=True, help="Asset provenance JSON path")
|
|
372
|
+
command.add_argument("--root", help="Directory used to resolve and hash asset files")
|
|
373
|
+
command.add_argument("--mode", choices=("contract", "runtime", "production"), default="contract")
|
|
374
|
+
command.add_argument("--manifest", help="Optional scene manifest used for binding checks")
|
|
375
|
+
command.add_argument("--json", action="store_true", help="Emit JSON output")
|
|
376
|
+
args = parser.parse_args()
|
|
377
|
+
path = Path(args.input).expanduser().resolve()
|
|
378
|
+
base = Path(args.root).expanduser().resolve() if args.root else None
|
|
379
|
+
manifest = read_json(Path(args.manifest).expanduser().resolve()) if args.manifest else None
|
|
380
|
+
result = evaluate(path, base=base, mode=args.mode, manifest=manifest)
|
|
381
|
+
if args.command == "classify" and result.get("summary"):
|
|
382
|
+
result = {"status": result.get("status"), "provenance_path": str(path), "summary": result["summary"], "errors": result.get("errors", [])}
|
|
383
|
+
if args.command == "validate":
|
|
384
|
+
result["mode"] = "contract"
|
|
385
|
+
print_result(result, as_json=args.json or args.command != "report")
|
|
386
|
+
return 0 if result.get("status") == "pass" else 1
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
if __name__ == "__main__":
|
|
390
|
+
raise SystemExit(main())
|
package/scripts/docs-audit.py
CHANGED
|
@@ -23,7 +23,7 @@ for markdown in sorted(ROOT.rglob("*.md")):
|
|
|
23
23
|
if not (markdown.parent / target).resolve().exists():
|
|
24
24
|
errors.append(f"{markdown.relative_to(ROOT)} -> missing {target}")
|
|
25
25
|
|
|
26
|
-
for relative in ["package.json", "agent-card.json", "agent-surfaces.json", "schemas/agent-surfaces.schema.json", "schemas/visual-truth.schema.json", "schemas/remediation-history.schema.json", "project-context.example.json", "tests/evals/project-corpus.json"]:
|
|
26
|
+
for relative in ["package.json", "agent-card.json", "agent-surfaces.json", "schemas/agent-surfaces.schema.json", "schemas/provenance.schema.json", "schemas/asset-provenance.schema.json", "schemas/scene-manifest.schema.json", "schemas/visual-truth.schema.json", "schemas/remediation-history.schema.json", "project-context.example.json", "examples/agent-consumer/ai-generated-pilot-provenance.json", "tests/evals/project-corpus.json"]:
|
|
27
27
|
path = ROOT / relative
|
|
28
28
|
try:
|
|
29
29
|
json.loads(path.read_text(encoding="utf-8"))
|
|
@@ -39,6 +39,12 @@ if package.get("packageManager") != "pnpm@11.20.0":
|
|
|
39
39
|
for required_surface in [".agents", ".claude", ".codex", "AGENTS.md", "agent-surfaces.json"]:
|
|
40
40
|
if required_surface not in package.get("files", []):
|
|
41
41
|
errors.append(f"package.json: files must include Agent surface {required_surface}")
|
|
42
|
+
for required_path in ["scripts/asset-provenance.py", "schemas/asset-provenance.schema.json", "examples/agent-consumer/ai-generated-pilot-provenance.json"]:
|
|
43
|
+
if required_path not in package.get("files", []):
|
|
44
|
+
errors.append(f"package.json: files must include asset provenance contract {required_path}")
|
|
45
|
+
for onboarding_script in ["setup", "setup:dry", "status", "repair"]:
|
|
46
|
+
if onboarding_script not in package.get("scripts", {}):
|
|
47
|
+
errors.append(f"package.json: missing onboarding script {onboarding_script}")
|
|
42
48
|
|
|
43
49
|
sys.path.insert(0, str(ROOT))
|
|
44
50
|
try:
|
|
@@ -54,9 +60,13 @@ for required_doc in ["docs/AGENT-INTEGRATION.md", "references/agent-interoperabi
|
|
|
54
60
|
errors.append(f"missing Agent interoperability document: {required_doc}")
|
|
55
61
|
|
|
56
62
|
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
57
|
-
for heading in ["Why MotionLoom", "Quick start", "Durable Project Memory", "Evidence, trust and review", "Documentation map"]:
|
|
63
|
+
for heading in ["Why MotionLoom", "Quick start", "Durable Project Memory", "Evidence, trust and review", "Asset provenance tiers", "Documentation map"]:
|
|
58
64
|
if f"## {heading}" not in readme:
|
|
59
65
|
errors.append(f"README.md: missing heading {heading}")
|
|
66
|
+
if "npx --yes motionloom setup" not in readme:
|
|
67
|
+
errors.append("README.md: missing one-command onboarding recipe")
|
|
68
|
+
if "npx --yes motionloom setup" not in (ROOT / "docs/AGENT-INTEGRATION.md").read_text(encoding="utf-8"):
|
|
69
|
+
errors.append("docs/AGENT-INTEGRATION.md: missing one-command onboarding recipe")
|
|
60
70
|
|
|
61
71
|
workflow_dir = ROOT / ".github" / "workflows"
|
|
62
72
|
for workflow in sorted(workflow_dir.glob("*.yml")):
|
|
@@ -77,6 +87,8 @@ for workflow in sorted(workflow_dir.glob("*.yml")):
|
|
|
77
87
|
for required in ["name:", "on:", "jobs:", "permissions:"]:
|
|
78
88
|
if required not in text:
|
|
79
89
|
errors.append(f"{workflow.relative_to(ROOT)}: missing {required}")
|
|
90
|
+
if "--require-asset-provenance" not in (workflow_dir / "quality.yml").read_text(encoding="utf-8"):
|
|
91
|
+
errors.append("quality.yml: missing fail-closed asset provenance production gate")
|
|
80
92
|
if "pull_request:" in text and "secrets." in text:
|
|
81
93
|
errors.append(f"{workflow.relative_to(ROOT)}: secrets referenced in pull_request workflow")
|
|
82
94
|
|
package/scripts/pr.py
CHANGED
|
@@ -95,6 +95,7 @@ def main() -> int:
|
|
|
95
95
|
"--task-dir", str(task_dir),
|
|
96
96
|
"--require-browser-review",
|
|
97
97
|
"--require-visual-truth",
|
|
98
|
+
"--require-asset-provenance",
|
|
98
99
|
]
|
|
99
100
|
print("== running context-bound quality gate ==")
|
|
100
101
|
run(repo, [python, *quality_args])
|
|
@@ -121,6 +122,7 @@ def main() -> int:
|
|
|
121
122
|
f"- snapshot frames: 0/50/100% in src/output/{args.scene}/snapshot/\n"
|
|
122
123
|
"- context-bound quality gate: passed\n"
|
|
123
124
|
f"- brand tokens bound from {args.context or 'project-context.json'}"
|
|
125
|
+
"\n- asset provenance: production_eligible (human approval remains separate)"
|
|
124
126
|
)
|
|
125
127
|
run(repo, ["git", "commit", "-m", commit_message])
|
|
126
128
|
|
package/scripts/quality-gate.py
CHANGED
|
@@ -55,6 +55,14 @@ def _load_visual_truth():
|
|
|
55
55
|
return module
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
def _load_asset_provenance():
|
|
59
|
+
path = ROOT / "scripts" / "asset-provenance.py"
|
|
60
|
+
loader = importlib.util.spec_from_file_location("asset_provenance", path)
|
|
61
|
+
module = importlib.util.module_from_spec(loader)
|
|
62
|
+
loader.loader.exec_module(module)
|
|
63
|
+
return module
|
|
64
|
+
|
|
65
|
+
|
|
58
66
|
def _json(path: Path):
|
|
59
67
|
try:
|
|
60
68
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
@@ -74,7 +82,7 @@ def _telemetry_bundle_sha256(task_dir: Path) -> str:
|
|
|
74
82
|
return hashlib.sha256(json.dumps(entries, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
|
75
83
|
|
|
76
84
|
|
|
77
|
-
def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = False, task_dir: Path | None = None, require_intelligence: bool = False, require_p1: bool = False, require_benchmark: bool = False, require_telemetry: bool = False, require_attestation: bool = False, attestation_path: Path | None = None, trust_policy_path: Path | None = None, require_visual_truth: bool = False) -> list[str]:
|
|
85
|
+
def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = False, task_dir: Path | None = None, require_intelligence: bool = False, require_p1: bool = False, require_benchmark: bool = False, require_telemetry: bool = False, require_attestation: bool = False, attestation_path: Path | None = None, trust_policy_path: Path | None = None, require_visual_truth: bool = False, require_asset_provenance: bool = False, asset_provenance_path: Path | None = None) -> list[str]:
|
|
78
86
|
issues = []
|
|
79
87
|
manifest_path = scene_dir / "manifest.json"
|
|
80
88
|
spec_path = scene_dir / "motion-spec.json"
|
|
@@ -108,6 +116,28 @@ def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = F
|
|
|
108
116
|
if not (spec.get("accessibility") or {}).get("reduced_motion"):
|
|
109
117
|
issues.append("motion spec has no reduced-motion policy")
|
|
110
118
|
|
|
119
|
+
asset_provenance_name = manifest.get("asset_provenance")
|
|
120
|
+
if require_asset_provenance and not isinstance(asset_provenance_name, str):
|
|
121
|
+
issues.append("asset provenance gate requires manifest.asset_provenance")
|
|
122
|
+
if asset_provenance_name or asset_provenance_path:
|
|
123
|
+
resolved_asset_provenance = asset_provenance_path or (scene_dir / str(asset_provenance_name)).resolve()
|
|
124
|
+
if scene_dir.resolve() not in resolved_asset_provenance.parents or not resolved_asset_provenance.is_file():
|
|
125
|
+
issues.append("manifest.asset_provenance must point to an existing file inside the scene directory")
|
|
126
|
+
else:
|
|
127
|
+
try:
|
|
128
|
+
asset_module = _load_asset_provenance()
|
|
129
|
+
asset_result = asset_module.evaluate(
|
|
130
|
+
resolved_asset_provenance,
|
|
131
|
+
base=scene_dir,
|
|
132
|
+
mode="production" if require_asset_provenance else "runtime",
|
|
133
|
+
manifest=manifest,
|
|
134
|
+
)
|
|
135
|
+
issues.extend(f"asset provenance: {issue}" for issue in asset_result.get("errors", []))
|
|
136
|
+
if require_asset_provenance and not asset_result.get("summary", {}).get("production_eligible"):
|
|
137
|
+
issues.append("asset provenance is not production_eligible; human approval remains separate")
|
|
138
|
+
except (OSError, ValueError, AttributeError) as exc:
|
|
139
|
+
issues.append(f"asset provenance contract: {exc}")
|
|
140
|
+
|
|
111
141
|
checks = manifest.get("checks")
|
|
112
142
|
if not isinstance(checks, list) or not checks:
|
|
113
143
|
issues.append("manifest.checks must contain the Dev Lab quality checklist")
|
|
@@ -382,6 +412,8 @@ def main() -> int:
|
|
|
382
412
|
parser.add_argument("--require-telemetry", action="store_true")
|
|
383
413
|
parser.add_argument("--require-attestation", action="store_true")
|
|
384
414
|
parser.add_argument("--require-visual-truth", action="store_true")
|
|
415
|
+
parser.add_argument("--require-asset-provenance", action="store_true")
|
|
416
|
+
parser.add_argument("--asset-provenance")
|
|
385
417
|
parser.add_argument("--attestation")
|
|
386
418
|
parser.add_argument("--trust-policy")
|
|
387
419
|
args = parser.parse_args()
|
|
@@ -396,20 +428,26 @@ def main() -> int:
|
|
|
396
428
|
task_dir = Path(args.task_dir).resolve() if args.task_dir else None
|
|
397
429
|
attestation_path = Path(args.attestation).resolve() if args.attestation else None
|
|
398
430
|
trust_policy_path = Path(args.trust_policy).resolve() if args.trust_policy else None
|
|
431
|
+
asset_provenance_path = Path(args.asset_provenance).resolve() if args.asset_provenance else None
|
|
399
432
|
scenes = [root / "src" / "output" / args.scene] if args.scene else sorted(p for p in output_root.iterdir() if p.is_dir()) if output_root.exists() else []
|
|
400
433
|
if not scenes:
|
|
401
434
|
print("QUALITY GATE: no scene outputs found")
|
|
402
435
|
return 0
|
|
403
436
|
failed = False
|
|
404
437
|
for scene_dir in scenes:
|
|
405
|
-
issues = validate_scene(scene_dir, context, args.require_browser_review, task_dir, args.require_intelligence, args.require_p1, args.require_benchmark, args.require_telemetry, args.require_attestation, attestation_path, trust_policy_path, args.require_visual_truth)
|
|
438
|
+
issues = validate_scene(scene_dir, context, args.require_browser_review, task_dir, args.require_intelligence, args.require_p1, args.require_benchmark, args.require_telemetry, args.require_attestation, attestation_path, trust_policy_path, args.require_visual_truth, args.require_asset_provenance, asset_provenance_path)
|
|
406
439
|
if issues:
|
|
407
440
|
failed = True
|
|
408
441
|
print(f"REJECTED {scene_dir.name}:")
|
|
409
442
|
for issue in issues:
|
|
410
443
|
print(f" - {issue}")
|
|
411
444
|
else:
|
|
412
|
-
|
|
445
|
+
suffixes = []
|
|
446
|
+
if args.require_visual_truth:
|
|
447
|
+
suffixes.append("visual-truth contract")
|
|
448
|
+
if args.require_asset_provenance:
|
|
449
|
+
suffixes.append("asset provenance production eligibility")
|
|
450
|
+
suffix = f" + {' + '.join(suffixes)}" if suffixes else ""
|
|
413
451
|
print(f"ACCEPTED {scene_dir.name}: context + spec + runtime snapshots + browser-review candidate + checklist{suffix}")
|
|
414
452
|
return 1 if failed else 0
|
|
415
453
|
|
package/scripts/report.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
7
|
import hashlib
|
|
8
|
+
import importlib.util
|
|
8
9
|
import json
|
|
9
10
|
import shutil
|
|
10
11
|
import sys
|
|
@@ -54,6 +55,32 @@ def project_memory_path() -> Path:
|
|
|
54
55
|
return ROOT / ".motionloom" / "project-memory.json"
|
|
55
56
|
|
|
56
57
|
|
|
58
|
+
def asset_provenance_module():
|
|
59
|
+
path = ROOT / "scripts" / "asset-provenance.py"
|
|
60
|
+
loader = importlib.util.spec_from_file_location("motionloom_asset_provenance", path)
|
|
61
|
+
module = importlib.util.module_from_spec(loader)
|
|
62
|
+
loader.loader.exec_module(module)
|
|
63
|
+
return module
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def asset_provenance_result(scene_manifest_path: Path, scene_manifest: dict, mode: str = "runtime") -> dict:
|
|
67
|
+
name = scene_manifest.get("asset_provenance")
|
|
68
|
+
if not name:
|
|
69
|
+
return {"status": "not-run", "errors": ["scene manifest has no asset_provenance"]}
|
|
70
|
+
provenance_path = (scene_manifest_path.parent / str(name)).resolve()
|
|
71
|
+
if scene_manifest_path.parent.resolve() not in provenance_path.parents or not provenance_path.is_file():
|
|
72
|
+
return {"status": "fail", "errors": ["scene manifest asset_provenance points to a missing or unsafe artifact"]}
|
|
73
|
+
try:
|
|
74
|
+
return asset_provenance_module().evaluate(
|
|
75
|
+
provenance_path,
|
|
76
|
+
base=scene_manifest_path.parent,
|
|
77
|
+
mode=mode,
|
|
78
|
+
manifest=scene_manifest,
|
|
79
|
+
)
|
|
80
|
+
except (OSError, ValueError, AttributeError) as exc:
|
|
81
|
+
return {"status": "fail", "errors": [f"asset provenance contract: {exc}"]}
|
|
82
|
+
|
|
83
|
+
|
|
57
84
|
def memory_summary() -> dict | None:
|
|
58
85
|
path = project_memory_path()
|
|
59
86
|
if not path.is_file():
|
|
@@ -433,6 +460,21 @@ def check_report(args: argparse.Namespace) -> int:
|
|
|
433
460
|
visual_truth_path = scene_manifest_path.parent / str(visual_truth_name)
|
|
434
461
|
if not visual_truth_path.is_file():
|
|
435
462
|
errors.append("scene manifest visual_truth points to a missing artifact")
|
|
463
|
+
provenance_mode = "production" if state in {"ready_for_pr", "confirmed"} else "runtime"
|
|
464
|
+
provenance = asset_provenance_result(scene_manifest_path, scene_manifest, provenance_mode)
|
|
465
|
+
if provenance.get("status") == "fail":
|
|
466
|
+
errors.extend(f"asset provenance: {error}" for error in provenance.get("errors", []))
|
|
467
|
+
# Legacy report-contract fixtures may exercise lifecycle/report behavior
|
|
468
|
+
# without materializing a scene manifest. Do not invent provenance for
|
|
469
|
+
# those synthetic tasks. Once a real scene manifest exists, readiness is
|
|
470
|
+
# fail-closed and its asset_provenance reference is mandatory for PR
|
|
471
|
+
# states; the production quality gate remains independently strict when
|
|
472
|
+
# --require-asset-provenance is supplied.
|
|
473
|
+
if state in {"ready_for_pr", "confirmed"} and scene_manifest_path.is_file():
|
|
474
|
+
if provenance.get("status") != "pass":
|
|
475
|
+
errors.append("ready-for-PR or confirmed task requires a passing asset provenance production check")
|
|
476
|
+
elif not provenance.get("summary", {}).get("production_eligible"):
|
|
477
|
+
errors.append("ready-for-PR or confirmed task requires asset provenance production_eligible")
|
|
436
478
|
if state in {"validated", "ready_for_pr", "confirmed"}:
|
|
437
479
|
quality = read_json(task_dir / "quality-report.json")
|
|
438
480
|
if quality.get("status") != "pass":
|
|
@@ -529,6 +571,11 @@ def render(args: argparse.Namespace) -> int:
|
|
|
529
571
|
ROOT / "src" / "output" / str(task.get("scene", "")) / str(scene_manifest.get("visual_truth", "")),
|
|
530
572
|
{},
|
|
531
573
|
) if scene_manifest.get("visual_truth") else {}
|
|
574
|
+
provenance = asset_provenance_result(
|
|
575
|
+
ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json",
|
|
576
|
+
scene_manifest,
|
|
577
|
+
"production" if task.get("state") in {"ready_for_pr", "confirmed"} else "runtime",
|
|
578
|
+
)
|
|
532
579
|
lines = [
|
|
533
580
|
f"# Animation Task Report — {task.get('task_id', task_dir.name)}",
|
|
534
581
|
"",
|
|
@@ -559,6 +606,9 @@ def render(args: argparse.Namespace) -> int:
|
|
|
559
606
|
f"- Status: **{visual_truth.get('status', 'not-run')}**; scene: `{visual_truth.get('scene', task.get('scene', ''))}`; approval: **{visual_truth.get('review_boundary', {}).get('approval', False)}**",
|
|
560
607
|
f"- Baseline: `{visual_truth.get('frames', {}).get('baseline', {}).get('path', '')}`; candidate: `{visual_truth.get('frames', {}).get('candidate', {}).get('path', '')}`",
|
|
561
608
|
f"- Changed pixels: **{visual_truth.get('comparison', {}).get('changed_pixels', 'not-run')}**; changed regions: **{len(visual_truth.get('comparison', {}).get('regions', []))}**",
|
|
609
|
+
"## Asset provenance",
|
|
610
|
+
f"- Status: **{provenance.get('status', 'not-run')}**; authority: **{provenance.get('summary', {}).get('authority', 'unknown')}**; declared readiness: **{provenance.get('summary', {}).get('declared_readiness', 'blocked')}**; effective readiness: **{provenance.get('summary', {}).get('effective_readiness', 'blocked')}**",
|
|
611
|
+
f"- Production eligible: **{provenance.get('summary', {}).get('production_eligible', False)}**; production approved: **{provenance.get('summary', {}).get('production_approved', False)}**; errors: **{len(provenance.get('errors', []))}**",
|
|
562
612
|
"## Semantic motion lint",
|
|
563
613
|
f"- Status: **{lint.get('status', 'not-run')}**; errors: **{lint.get('summary', {}).get('errors', 0)}**; warnings: **{lint.get('summary', {}).get('warnings', 0)}**; blocking: **{lint.get('summary', {}).get('blocking', 0)}**",
|
|
564
614
|
md_table(lint.get("findings", []), [("Rule", "rule_id"), ("Severity", "severity"), ("Confidence", "confidence"), ("Message", "message"), ("Basis", "basis")]),
|