motionloom 2.3.0 → 2.5.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 +45 -1
- package/README.md +97 -11
- package/ROADMAP.md +8 -5
- package/SKILL.md +24 -5
- package/agent-card.json +34 -5
- package/agent-surfaces.json +1 -1
- package/artifact-adapter-registry.json +53 -0
- package/bin/motionloom.mjs +43 -6
- package/capability-registry.json +308 -0
- package/docs/CHECKLIST.md +16 -0
- package/docs/STATUS.md +2 -2
- package/docs/apple-distribution.md +24 -0
- package/docs/architecture/apple/cloudkit.md +18 -0
- package/docs/architecture/apple/contracts.md +9 -0
- package/docs/architecture/apple/implementation-status-2026-08-16.md +55 -0
- package/docs/architecture/apple/studio.md +35 -0
- package/docs/audits/data/deep-stress-latest.json +65 -65
- package/docs/releases/2.4.0.md +31 -0
- package/docs/releases/2.5.0.md +31 -0
- package/docs/releases/npm-publish-from-workstation.md +19 -8
- package/docs/research/agent-skill-ecosystem-notes.md +47 -0
- package/docs/research/ai-animation-tools-2026-notes.md +81 -0
- package/docs/research/ai-animation-tools-2026-report.md +168 -0
- package/docs/research/ai-pilot-ingest-notes.md +29 -0
- package/docs/research/ai-repository-benchmark-2026-08-16.md +75 -0
- package/examples/agent-consumer/ai-pilot-scout/CHATGPT-HANDOFF.md +70 -0
- package/examples/agent-consumer/ai-pilot-scout/CODEX-HANDOFF.md +80 -0
- package/examples/agent-consumer/ai-pilot-scout/README.md +39 -0
- package/examples/agent-consumer/ai-pilot-scout/chatgpt-geometry-correction-prompt.md +55 -0
- package/examples/agent-consumer/ai-pilot-scout/chatgpt-pose-generation-prompt.md +78 -0
- package/examples/agent-consumer/ai-pilot-scout/partial-handoff.json +73 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-controls.json +15 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-export.json +14 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-provenance.json +20 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-receipt.json +16 -0
- package/examples/agent-consumer/asset-consistency/assets/forest-back.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/forest-front.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/forest-mid.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-atlas.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-00.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-01.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-02.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-03.png +0 -0
- package/examples/agent-consumer/asset-consistency/forest-layered-map.json +53 -0
- package/examples/agent-consumer/asset-consistency/hero-atlas-contract.json +24 -0
- package/examples/agent-consumer/asset-consistency/hero-identity.json +51 -0
- package/examples/agent-consumer/asset-consistency/hero-walk-action-set.json +27 -0
- package/examples/agent-consumer/asset-consistency/hero-walk-frame-geometry.json +63 -0
- package/examples/agent-consumer/rig-compatibility/hero-walk-fixture-rig.json +13 -0
- package/examples/agent-consumer/rive-package-gate/README.md +20 -0
- package/examples/agent-consumer/runtime-candidate/hero-walk-candidate.json +18 -0
- package/examples/agent-consumer/runtime-pilot/action-set.json +19 -0
- package/examples/agent-consumer/runtime-pilot/asset-identity.json +44 -0
- package/examples/agent-consumer/runtime-pilot/candidate.json +21 -0
- package/examples/agent-consumer/runtime-pilot/controls.json +35 -0
- package/examples/agent-consumer/runtime-pilot/export.json +17 -0
- package/examples/agent-consumer/runtime-pilot/provenance.json +38 -0
- package/examples/agent-consumer/runtime-pilot/receipt.json +30 -0
- package/package.json +58 -4
- package/references/browser-review-contract.md +7 -1
- package/references/intelligence-core.md +22 -1
- package/rig-adapter-registry.json +39 -0
- package/schemas/action-set.schema.json +42 -0
- package/schemas/artifact-adapter-registry.schema.json +16 -0
- package/schemas/asset-identity.schema.json +117 -0
- package/schemas/asset-provenance.schema.json +1 -1
- package/schemas/atlas-contract.schema.json +48 -0
- package/schemas/control-track.schema.json +18 -0
- package/schemas/export-manifest.schema.json +19 -0
- package/schemas/frame-geometry.schema.json +79 -0
- package/schemas/generation-receipt.schema.json +20 -0
- package/schemas/layered-map.schema.json +75 -0
- package/schemas/rig-adapter-registry.schema.json +14 -0
- package/schemas/rig-compatibility.schema.json +53 -0
- package/schemas/rive-package-manifest.schema.json +43 -0
- package/schemas/runtime-candidate.schema.json +48 -0
- package/schemas/scene-manifest.schema.json +18 -0
- package/scripts/artifact-intake.py +408 -0
- package/scripts/asset-consistency.py +517 -0
- package/scripts/asset-provenance.py +8 -3
- package/scripts/attestation.py +8 -0
- package/scripts/build-ai-pilot.py +504 -0
- package/scripts/evidence-verifier.py +18 -9
- package/scripts/intelligence.py +85 -18
- package/scripts/isolate-alpha-background.py +147 -0
- package/scripts/quality-gate.py +88 -2
- package/scripts/report.py +85 -0
- package/scripts/resolve-task-bundle.py +84 -0
- package/scripts/review-hook.py +26 -4
- package/scripts/rig-compatibility.py +251 -0
- package/scripts/rive-package-gate.py +245 -0
- package/scripts/runtime-candidate.py +301 -0
- package/scripts/setup.mjs +54 -26
- package/src/output/runtime-pilot-framer/asset-provenance.json +38 -0
- package/src/output/runtime-pilot-framer/browser-review.json +20 -0
- package/src/output/runtime-pilot-framer/framer-motion/runtime-telemetry.json +103 -0
- package/src/output/runtime-pilot-framer/manifest.json +31 -0
- package/src/output/runtime-pilot-framer/motion-spec.json +25 -0
- package/src/output/runtime-pilot-framer/runtime-evidence.json +96 -0
- package/src/output/runtime-pilot-framer/runtime-telemetry.json +100 -0
- package/src/output/runtime-pilot-framer/scene.jsx +52 -0
- package/src/output/runtime-pilot-framer/snapshot/.render-meta.json +9 -0
- package/src/output/runtime-pilot-framer/snapshot/frame-00.png +0 -0
- package/src/output/runtime-pilot-framer/snapshot/frame-100.png +0 -0
- package/src/output/runtime-pilot-framer/snapshot/frame-50.png +0 -0
- package/tests/scripts/run_tests.py +151 -0
- package/tests/scripts/test_ai_pilot_builder.py +128 -0
- package/tests/scripts/test_alpha_isolation.py +60 -0
- package/tests/scripts/test_artifact_intake.py +146 -0
- package/tests/scripts/test_asset_consistency.py +199 -0
- package/tests/scripts/test_rig_compatibility.py +105 -0
- package/tests/scripts/test_rive_package_gate.py +101 -0
- package/tests/scripts/test_runtime_candidate.py +95 -0
package/scripts/intelligence.py
CHANGED
|
@@ -82,14 +82,22 @@ def safe_relative(value: str) -> Path:
|
|
|
82
82
|
return path
|
|
83
83
|
|
|
84
84
|
|
|
85
|
-
def has_symlink_component(path: Path) -> bool:
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
def has_symlink_component(path: Path, root: Path) -> bool:
|
|
86
|
+
"""Reject symlinks at or below a trusted root, not system parent aliases."""
|
|
87
|
+
root = root.absolute()
|
|
88
|
+
path = path.absolute()
|
|
89
|
+
try:
|
|
90
|
+
relative = path.relative_to(root)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return True
|
|
93
|
+
if root.is_symlink():
|
|
94
|
+
return True
|
|
95
|
+
current = root
|
|
96
|
+
for component in relative.parts:
|
|
97
|
+
current = current / component
|
|
88
98
|
if current.is_symlink():
|
|
89
99
|
return True
|
|
90
|
-
|
|
91
|
-
return False
|
|
92
|
-
current = current.parent
|
|
100
|
+
return False
|
|
93
101
|
|
|
94
102
|
|
|
95
103
|
def within(root: Path, candidate: Path) -> bool:
|
|
@@ -104,7 +112,7 @@ def task_artifact(task_dir: Path, relative: str) -> dict[str, Any]:
|
|
|
104
112
|
safe = safe_relative(relative)
|
|
105
113
|
raw_path = task_dir / safe
|
|
106
114
|
path = raw_path.resolve()
|
|
107
|
-
if has_symlink_component(raw_path) or not within(task_dir, path) or not path.is_file():
|
|
115
|
+
if has_symlink_component(raw_path, task_dir) or not within(task_dir, path) or not path.is_file():
|
|
108
116
|
raise ValueError(f"artifact is missing or outside task bundle: {relative}")
|
|
109
117
|
return {
|
|
110
118
|
"id": relative.replace("/", ":"),
|
|
@@ -179,7 +187,7 @@ def graph_build(args: argparse.Namespace) -> int:
|
|
|
179
187
|
|
|
180
188
|
task_files = []
|
|
181
189
|
for path in sorted(task_dir.rglob("*")):
|
|
182
|
-
if has_symlink_component(path):
|
|
190
|
+
if has_symlink_component(path, task_dir):
|
|
183
191
|
raise ValueError(f"task bundle cannot contain symlinked artifact: {path.relative_to(task_dir)}")
|
|
184
192
|
if not path.is_file():
|
|
185
193
|
continue
|
|
@@ -363,7 +371,7 @@ def provenance_validate(args: argparse.Namespace) -> int:
|
|
|
363
371
|
if not isinstance(attestation, dict) or not isinstance(attestation.get("steps"), list):
|
|
364
372
|
raise ValueError("provenance attestation must contain steps")
|
|
365
373
|
task_dir = task_dir_from(args.task_dir)
|
|
366
|
-
if has_symlink_component(
|
|
374
|
+
if has_symlink_component(path, task_dir) or not within(task_dir, path):
|
|
367
375
|
raise ValueError("provenance attestation must be a non-symlink file inside task bundle")
|
|
368
376
|
if attestation.get("task_id") != read_json(task_dir / "task.json").get("task_id"):
|
|
369
377
|
raise ValueError("provenance task_id does not match task.json")
|
|
@@ -400,13 +408,13 @@ def capability_build(args: argparse.Namespace) -> int:
|
|
|
400
408
|
if not isinstance(card, dict):
|
|
401
409
|
raise ValueError("agent-card must be an object")
|
|
402
410
|
raw_evidence_path = Path(args.evidence).expanduser() if args.evidence else ROOT / "scripts" / "runtime-adapters.mjs"
|
|
403
|
-
if has_symlink_component(raw_evidence_path):
|
|
404
|
-
raise ValueError("capability evidence cannot traverse symlinks")
|
|
405
411
|
evidence_path = raw_evidence_path.resolve()
|
|
406
412
|
if not evidence_path.is_file():
|
|
407
413
|
raise ValueError(f"capability evidence path does not exist: {evidence_path}")
|
|
408
414
|
if not within(ROOT, evidence_path):
|
|
409
415
|
raise ValueError("capability evidence must be inside the repository")
|
|
416
|
+
if has_symlink_component(raw_evidence_path, ROOT):
|
|
417
|
+
raise ValueError("capability evidence cannot traverse symlinks")
|
|
410
418
|
evidence_ref = evidence_path.relative_to(ROOT).as_posix()
|
|
411
419
|
evidence_kind = args.evidence_kind
|
|
412
420
|
entries = []
|
|
@@ -445,6 +453,13 @@ def capability_build(args: argparse.Namespace) -> int:
|
|
|
445
453
|
def capability_validate(args: argparse.Namespace) -> int:
|
|
446
454
|
registry_path = Path(args.path).expanduser().resolve()
|
|
447
455
|
registry = read_json(registry_path)
|
|
456
|
+
capability_registry_validate(registry)
|
|
457
|
+
ids = [entry.get("id") for entry in registry["capabilities"]]
|
|
458
|
+
print(json.dumps({"status": "valid", "kind": "capability-registry", "capability_count": len(ids)}, ensure_ascii=False))
|
|
459
|
+
return 0
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def capability_registry_validate(registry: Any) -> None:
|
|
448
463
|
if not isinstance(registry, dict) or not isinstance(registry.get("capabilities"), list):
|
|
449
464
|
raise ValueError("capability registry must contain capabilities")
|
|
450
465
|
ids = [entry.get("id") for entry in registry["capabilities"]]
|
|
@@ -453,13 +468,61 @@ def capability_validate(args: argparse.Namespace) -> int:
|
|
|
453
468
|
for entry in registry["capabilities"]:
|
|
454
469
|
for evidence in entry.get("evidence", []):
|
|
455
470
|
evidence_path = ROOT / safe_relative(str(evidence.get("path", "")))
|
|
456
|
-
if has_symlink_component(evidence_path) or not within(ROOT, evidence_path):
|
|
471
|
+
if has_symlink_component(evidence_path, ROOT) or not within(ROOT, evidence_path):
|
|
457
472
|
raise ValueError(f"capability evidence escapes repository: {evidence.get('path')}")
|
|
458
473
|
if not evidence_path.is_file():
|
|
459
474
|
raise ValueError(f"capability evidence missing: {evidence.get('path')}")
|
|
460
475
|
if digest_file(evidence_path) != evidence.get("sha256"):
|
|
461
476
|
raise ValueError(f"capability evidence hash mismatch: {evidence.get('path')}")
|
|
462
|
-
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def capability_card(args: argparse.Namespace) -> int:
|
|
480
|
+
"""Export a read-only, evidence-bound capability projection for Agents.
|
|
481
|
+
|
|
482
|
+
The card deliberately exposes declared registry state only. It does not
|
|
483
|
+
select a runtime, refresh evidence, grant approval, or change policy.
|
|
484
|
+
Callers must run ``capabilities select`` before execution.
|
|
485
|
+
"""
|
|
486
|
+
registry = read_json(Path(args.registry).expanduser().resolve())
|
|
487
|
+
capability_registry_validate(registry)
|
|
488
|
+
capabilities = []
|
|
489
|
+
for entry in registry["capabilities"]:
|
|
490
|
+
capabilities.append({
|
|
491
|
+
"id": entry.get("id"),
|
|
492
|
+
"kind": entry.get("kind"),
|
|
493
|
+
"declared_status": entry.get("status"),
|
|
494
|
+
"adapter_version": entry.get("adapter_version"),
|
|
495
|
+
"last_verified_at": entry.get("last_verified_at"),
|
|
496
|
+
"inputs": entry.get("inputs", []),
|
|
497
|
+
"outputs": entry.get("outputs", []),
|
|
498
|
+
"compatibility": entry.get("compatibility", {}),
|
|
499
|
+
"evidence": entry.get("evidence", []),
|
|
500
|
+
"limitations": entry.get("limitations", []),
|
|
501
|
+
"fallback": entry.get("fallback", ""),
|
|
502
|
+
"risk_level": entry.get("risk_level"),
|
|
503
|
+
"side_effect_level": entry.get("side_effect_level"),
|
|
504
|
+
})
|
|
505
|
+
card = {
|
|
506
|
+
"schema_version": "0.1",
|
|
507
|
+
"kind": "motionloom-capability-card",
|
|
508
|
+
"registry": {
|
|
509
|
+
"registry_id": registry.get("registry_id"),
|
|
510
|
+
"schema_version": registry.get("schema_version"),
|
|
511
|
+
"generated_at": registry.get("generated_at"),
|
|
512
|
+
"selection_policy": registry.get("selection_policy", {}),
|
|
513
|
+
},
|
|
514
|
+
"capabilities": capabilities,
|
|
515
|
+
"selection": {
|
|
516
|
+
"required": True,
|
|
517
|
+
"command": "motionloom capability select --registry <capability-registry.json> --capability <runtime.id>",
|
|
518
|
+
"note": "This card is discovery metadata. Select validates evidence freshness and integrity before a runtime may be used.",
|
|
519
|
+
},
|
|
520
|
+
"review": {
|
|
521
|
+
"production_approval": "not_derived",
|
|
522
|
+
"attestation_approval": "not_derived",
|
|
523
|
+
},
|
|
524
|
+
}
|
|
525
|
+
print(json.dumps(card, ensure_ascii=False))
|
|
463
526
|
return 0
|
|
464
527
|
|
|
465
528
|
|
|
@@ -488,7 +551,7 @@ def capability_select(args: argparse.Namespace) -> int:
|
|
|
488
551
|
for evidence in entry.get("evidence", []):
|
|
489
552
|
try:
|
|
490
553
|
evidence_path = ROOT / safe_relative(str(evidence.get("path", "")))
|
|
491
|
-
if has_symlink_component(evidence_path) or not within(ROOT, evidence_path) or not evidence_path.is_file() or digest_file(evidence_path) != evidence.get("sha256"):
|
|
554
|
+
if has_symlink_component(evidence_path, ROOT) or not within(ROOT, evidence_path) or not evidence_path.is_file() or digest_file(evidence_path) != evidence.get("sha256"):
|
|
492
555
|
evidence_ok = False
|
|
493
556
|
break
|
|
494
557
|
except (OSError, ValueError):
|
|
@@ -1383,7 +1446,7 @@ def replay_capture(args: argparse.Namespace) -> int:
|
|
|
1383
1446
|
task_rel = task_dir.relative_to(root).as_posix()
|
|
1384
1447
|
records = []
|
|
1385
1448
|
for path in sorted(task_dir.rglob("*")):
|
|
1386
|
-
if has_symlink_component(path):
|
|
1449
|
+
if has_symlink_component(path, task_dir):
|
|
1387
1450
|
raise ValueError(f"task bundle cannot contain symlinked artifact: {path.relative_to(task_dir)}")
|
|
1388
1451
|
if not path.is_file():
|
|
1389
1452
|
continue
|
|
@@ -1417,7 +1480,7 @@ def replay_verify(args: argparse.Namespace) -> int:
|
|
|
1417
1480
|
task_rel = safe_relative(str(bundle.get("task_dir", "")))
|
|
1418
1481
|
task_dir = (root / task_rel).resolve()
|
|
1419
1482
|
task = read_json(task_dir / "task.json")
|
|
1420
|
-
if has_symlink_component(root / task_rel) or not within(root, task_dir) or not task_dir.is_dir():
|
|
1483
|
+
if has_symlink_component(root / task_rel, root) or not within(root, task_dir) or not task_dir.is_dir():
|
|
1421
1484
|
mismatches.append({"path": str(task_rel), "reason": "task_dir_invalid"})
|
|
1422
1485
|
else:
|
|
1423
1486
|
if bundle.get("task_id") != task.get("task_id"):
|
|
@@ -1430,7 +1493,7 @@ def replay_verify(args: argparse.Namespace) -> int:
|
|
|
1430
1493
|
safe = safe_relative(str(record.get("path", "")))
|
|
1431
1494
|
raw_path = root / safe
|
|
1432
1495
|
path = raw_path.resolve()
|
|
1433
|
-
if has_symlink_component(raw_path) or not within(root, path) or "task_dir" in locals() and not within(task_dir, path) or not path.is_file():
|
|
1496
|
+
if has_symlink_component(raw_path, root) or not within(root, path) or "task_dir" in locals() and not within(task_dir, path) or not path.is_file():
|
|
1434
1497
|
mismatches.append({"path": str(safe), "reason": "missing"})
|
|
1435
1498
|
continue
|
|
1436
1499
|
actual = digest_file(path)
|
|
@@ -1449,7 +1512,7 @@ def replay_mismatches(bundle: dict[str, Any], root: Path) -> list[dict[str, Any]
|
|
|
1449
1512
|
safe = safe_relative(str(record.get("path", "")))
|
|
1450
1513
|
raw_path = root / safe
|
|
1451
1514
|
path = raw_path.resolve()
|
|
1452
|
-
if has_symlink_component(raw_path) or not within(root, path) or not path.is_file():
|
|
1515
|
+
if has_symlink_component(raw_path, root) or not within(root, path) or not path.is_file():
|
|
1453
1516
|
mismatches.append({"path": str(safe), "reason": "missing"})
|
|
1454
1517
|
continue
|
|
1455
1518
|
actual = digest_file(path)
|
|
@@ -1563,6 +1626,10 @@ def add_subcommands(parser: argparse.ArgumentParser) -> None:
|
|
|
1563
1626
|
capabilities_select_parser.add_argument("--status", default="verified")
|
|
1564
1627
|
capabilities_select_parser.add_argument("--allow-scaffold-only", action="store_true")
|
|
1565
1628
|
capabilities_select_parser.set_defaults(func=capability_select)
|
|
1629
|
+
capabilities_card_parser = capabilities_sub.add_parser("card", help="export a read-only capability card for Agent discovery")
|
|
1630
|
+
capabilities_card_parser.add_argument("--registry", default=str(ROOT / "capability-registry.json"))
|
|
1631
|
+
capabilities_card_parser.add_argument("--format", choices=["json"], default="json")
|
|
1632
|
+
capabilities_card_parser.set_defaults(func=capability_card)
|
|
1566
1633
|
|
|
1567
1634
|
replay = sub.add_parser("replay", help="capture or verify clean-root integrity replay bundle")
|
|
1568
1635
|
replay_sub = replay.add_subparsers(dest="action", required=True)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministically isolate a high-contrast, edge-connected flat background.
|
|
3
|
+
|
|
4
|
+
This is a narrow recovery utility for generated PNGs where an image provider
|
|
5
|
+
painted a dark background despite a transparency request. It flood-fills only
|
|
6
|
+
edge-connected pixels within a stated RGB-distance of an observed edge color.
|
|
7
|
+
It does not claim the input was provider-native alpha, alter provenance, or
|
|
8
|
+
approve an asset. Review the JSON report before using the result in a bundle.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import hashlib
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import struct
|
|
18
|
+
import sys
|
|
19
|
+
import zlib
|
|
20
|
+
from collections import deque
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from statistics import median
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
27
|
+
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def digest(path: Path) -> str:
|
|
31
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_png_reader() -> Any:
|
|
35
|
+
source = ROOT / "scripts" / "asset-consistency.py"
|
|
36
|
+
spec = importlib.util.spec_from_file_location("motionloom_alpha_isolation_png", source)
|
|
37
|
+
if spec is None or spec.loader is None:
|
|
38
|
+
raise RuntimeError(f"could not load PNG reader: {source}")
|
|
39
|
+
module = importlib.util.module_from_spec(spec)
|
|
40
|
+
sys.modules[spec.name] = module
|
|
41
|
+
spec.loader.exec_module(module)
|
|
42
|
+
return module.PNGImage
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def median_edge_color(pixels: list[tuple[int, int, int, int]], width: int, height: int) -> tuple[int, int, int]:
|
|
46
|
+
indices = set(range(width)) | {width * (height - 1) + x for x in range(width)}
|
|
47
|
+
indices.update(y * width for y in range(height))
|
|
48
|
+
indices.update(y * width + width - 1 for y in range(height))
|
|
49
|
+
values = [pixels[index] for index in sorted(indices)]
|
|
50
|
+
return tuple(int(median([pixel[channel] for pixel in values])) for channel in range(3))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def distance(pixel: tuple[int, int, int, int], color: tuple[int, int, int]) -> int:
|
|
54
|
+
return sum(abs(pixel[index] - color[index]) for index in range(3))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def isolate(pixels: list[tuple[int, int, int, int]], width: int, height: int, background: tuple[int, int, int], tolerance: int) -> set[int]:
|
|
58
|
+
def matches(index: int) -> bool:
|
|
59
|
+
return pixels[index][3] > 0 and distance(pixels[index], background) <= tolerance
|
|
60
|
+
edges = set(range(width)) | {width * (height - 1) + x for x in range(width)}
|
|
61
|
+
edges.update(y * width for y in range(height))
|
|
62
|
+
edges.update(y * width + width - 1 for y in range(height))
|
|
63
|
+
queue = deque(index for index in edges if matches(index))
|
|
64
|
+
visited = set(queue)
|
|
65
|
+
while queue:
|
|
66
|
+
index = queue.popleft()
|
|
67
|
+
x, y = index % width, index // width
|
|
68
|
+
for neighbour in (index - 1 if x else None, index + 1 if x + 1 < width else None, index - width if y else None, index + width if y + 1 < height else None):
|
|
69
|
+
if neighbour is not None and neighbour not in visited and matches(neighbour):
|
|
70
|
+
visited.add(neighbour)
|
|
71
|
+
queue.append(neighbour)
|
|
72
|
+
return visited
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def png_rgba(width: int, height: int, pixels: list[tuple[int, int, int, int]]) -> bytes:
|
|
76
|
+
raw = bytearray()
|
|
77
|
+
for y in range(height):
|
|
78
|
+
raw.append(0)
|
|
79
|
+
for pixel in pixels[y * width : (y + 1) * width]:
|
|
80
|
+
raw.extend(pixel)
|
|
81
|
+
def chunk(kind: bytes, content: bytes) -> bytes:
|
|
82
|
+
return struct.pack(">I", len(content)) + kind + content + struct.pack(">I", zlib.crc32(kind + content) & 0xFFFFFFFF)
|
|
83
|
+
return PNG_SIGNATURE + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(bytes(raw), level=9)) + chunk(b"IEND", b"")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main(argv: list[str] | None = None) -> int:
|
|
87
|
+
parser = argparse.ArgumentParser(description="Flood-fill a flat edge-connected background into alpha=0")
|
|
88
|
+
parser.add_argument("input", type=Path)
|
|
89
|
+
parser.add_argument("output", type=Path)
|
|
90
|
+
parser.add_argument("--report", type=Path, help="Optional JSON evidence report")
|
|
91
|
+
parser.add_argument("--tolerance", type=int, default=24, help="Manhattan RGB distance from edge background; 0-765")
|
|
92
|
+
parser.add_argument("--allow-edge-foreground", action="store_true", help="Permit opaque pixels on the canvas edge; only use for intentionally edge-cropped art")
|
|
93
|
+
args = parser.parse_args(argv)
|
|
94
|
+
if not 0 <= args.tolerance <= 765:
|
|
95
|
+
raise SystemExit("--tolerance must be in 0..765")
|
|
96
|
+
if not args.input.is_file():
|
|
97
|
+
raise SystemExit(f"input is missing: {args.input}")
|
|
98
|
+
if args.output.resolve() == args.input.resolve():
|
|
99
|
+
raise SystemExit("output must differ from input")
|
|
100
|
+
PNGImage = load_png_reader()
|
|
101
|
+
image = PNGImage(args.input)
|
|
102
|
+
pixels = image.rgba()
|
|
103
|
+
background = median_edge_color(pixels, image.width, image.height)
|
|
104
|
+
removed = isolate(pixels, image.width, image.height, background, args.tolerance)
|
|
105
|
+
if not removed:
|
|
106
|
+
raise SystemExit("no edge-connected background pixels matched; do not use a larger tolerance without visual review")
|
|
107
|
+
output_pixels = [(r, g, b, 0 if index in removed else a) for index, (r, g, b, a) in enumerate(pixels)]
|
|
108
|
+
foreground = sum(1 for _, _, _, alpha in output_pixels if alpha > 0)
|
|
109
|
+
if foreground == 0:
|
|
110
|
+
raise SystemExit("isolation would remove all pixels; lower tolerance or provide a different source")
|
|
111
|
+
edge_indices = set(range(image.width)) | {image.width * (image.height - 1) + x for x in range(image.width)}
|
|
112
|
+
edge_indices.update(y * image.width for y in range(image.height))
|
|
113
|
+
edge_indices.update(y * image.width + image.width - 1 for y in range(image.height))
|
|
114
|
+
residual_edge_pixels = sum(1 for index in edge_indices if output_pixels[index][3] > 0)
|
|
115
|
+
if residual_edge_pixels and not args.allow_edge_foreground:
|
|
116
|
+
raise SystemExit(
|
|
117
|
+
f"{residual_edge_pixels} opaque pixel(s) remain at the canvas edge after isolation; "
|
|
118
|
+
"this is likely residual background contamination. Use a cleaner source, or explicitly review and pass --allow-edge-foreground for intentionally cropped art."
|
|
119
|
+
)
|
|
120
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
args.output.write_bytes(png_rgba(image.width, image.height, output_pixels))
|
|
122
|
+
report = {
|
|
123
|
+
"contract": "alpha-isolation-report",
|
|
124
|
+
"mode": "edge_connected_flat_background",
|
|
125
|
+
"input": str(args.input),
|
|
126
|
+
"input_sha256": digest(args.input),
|
|
127
|
+
"output": str(args.output),
|
|
128
|
+
"output_sha256": digest(args.output),
|
|
129
|
+
"canvas": {"width": image.width, "height": image.height},
|
|
130
|
+
"observed_edge_background_rgb": list(background),
|
|
131
|
+
"tolerance": args.tolerance,
|
|
132
|
+
"removed_pixels": len(removed),
|
|
133
|
+
"foreground_alpha_pixels": foreground,
|
|
134
|
+
"residual_edge_foreground_pixels": residual_edge_pixels,
|
|
135
|
+
"edge_foreground_allowed": args.allow_edge_foreground,
|
|
136
|
+
"human_review_required": True,
|
|
137
|
+
"production_approved": False,
|
|
138
|
+
}
|
|
139
|
+
if args.report:
|
|
140
|
+
args.report.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
args.report.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
142
|
+
print(json.dumps(report, indent=2, sort_keys=True))
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
raise SystemExit(main())
|
package/scripts/quality-gate.py
CHANGED
|
@@ -63,6 +63,24 @@ def _load_asset_provenance():
|
|
|
63
63
|
return module
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
def _load_asset_consistency():
|
|
67
|
+
path = ROOT / "scripts" / "asset-consistency.py"
|
|
68
|
+
loader = importlib.util.spec_from_file_location("asset_consistency", path)
|
|
69
|
+
module = importlib.util.module_from_spec(loader)
|
|
70
|
+
sys.modules[loader.name] = module
|
|
71
|
+
loader.loader.exec_module(module)
|
|
72
|
+
return module
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_artifact_intake():
|
|
76
|
+
path = ROOT / "scripts" / "artifact-intake.py"
|
|
77
|
+
loader = importlib.util.spec_from_file_location("artifact_intake", path)
|
|
78
|
+
module = importlib.util.module_from_spec(loader)
|
|
79
|
+
sys.modules[loader.name] = module
|
|
80
|
+
loader.loader.exec_module(module)
|
|
81
|
+
return module
|
|
82
|
+
|
|
83
|
+
|
|
66
84
|
def _json(path: Path):
|
|
67
85
|
try:
|
|
68
86
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
@@ -82,7 +100,7 @@ def _telemetry_bundle_sha256(task_dir: Path) -> str:
|
|
|
82
100
|
return hashlib.sha256(json.dumps(entries, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
|
83
101
|
|
|
84
102
|
|
|
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]:
|
|
103
|
+
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, require_asset_consistency: bool = False, asset_consistency_path: Path | None = None, require_artifact_intake: bool = False) -> list[str]:
|
|
86
104
|
issues = []
|
|
87
105
|
manifest_path = scene_dir / "manifest.json"
|
|
88
106
|
spec_path = scene_dir / "motion-spec.json"
|
|
@@ -138,6 +156,66 @@ def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = F
|
|
|
138
156
|
except (OSError, ValueError, AttributeError) as exc:
|
|
139
157
|
issues.append(f"asset provenance contract: {exc}")
|
|
140
158
|
|
|
159
|
+
consistency_name = manifest.get("consistency_ref")
|
|
160
|
+
if require_asset_consistency and not isinstance(consistency_name, str) and asset_consistency_path is None:
|
|
161
|
+
issues.append("asset consistency gate requires manifest.consistency_ref")
|
|
162
|
+
if consistency_name or asset_consistency_path:
|
|
163
|
+
resolved_consistency = asset_consistency_path or (scene_dir / str(consistency_name)).resolve()
|
|
164
|
+
if scene_dir.resolve() not in resolved_consistency.parents or not resolved_consistency.is_file():
|
|
165
|
+
issues.append("manifest.consistency_ref must point to an existing file inside the scene directory")
|
|
166
|
+
else:
|
|
167
|
+
try:
|
|
168
|
+
consistency_kind = manifest.get("consistency_kind", "frame-geometry")
|
|
169
|
+
consistency_module = _load_asset_consistency()
|
|
170
|
+
consistency_document = _json(resolved_consistency)
|
|
171
|
+
consistency_result = consistency_module.run(
|
|
172
|
+
consistency_kind,
|
|
173
|
+
consistency_document,
|
|
174
|
+
scene_dir,
|
|
175
|
+
strict=require_asset_consistency,
|
|
176
|
+
)
|
|
177
|
+
issues.extend(
|
|
178
|
+
f"asset consistency: {item.get('code', 'issue')} — {item.get('message', 'contract violation')}"
|
|
179
|
+
for item in consistency_result.get("errors", [])
|
|
180
|
+
)
|
|
181
|
+
if require_asset_consistency and not consistency_result.get("ready"):
|
|
182
|
+
issues.append("asset consistency contract is not ready")
|
|
183
|
+
except (OSError, ValueError, AttributeError, KeyError, TypeError) as exc:
|
|
184
|
+
issues.append(f"asset consistency contract: {exc}")
|
|
185
|
+
|
|
186
|
+
intake = manifest.get("artifact_intake")
|
|
187
|
+
if require_artifact_intake and not isinstance(intake, dict):
|
|
188
|
+
issues.append("artifact intake gate requires manifest.artifact_intake")
|
|
189
|
+
if intake is not None:
|
|
190
|
+
required_intake_keys = ("registry", "receipt", "controls", "export_manifest")
|
|
191
|
+
if not isinstance(intake, dict) or any(not isinstance(intake.get(key), str) for key in required_intake_keys):
|
|
192
|
+
issues.append("manifest.artifact_intake requires registry, receipt, controls and export_manifest relative paths")
|
|
193
|
+
else:
|
|
194
|
+
resolved_intake = {key: (scene_dir / intake[key]).resolve() for key in required_intake_keys}
|
|
195
|
+
if any(scene_dir.resolve() not in path.parents or not path.is_file() for path in resolved_intake.values()):
|
|
196
|
+
issues.append("manifest.artifact_intake paths must point to existing files inside the scene directory")
|
|
197
|
+
else:
|
|
198
|
+
try:
|
|
199
|
+
intake_module = _load_artifact_intake()
|
|
200
|
+
intake_result = intake_module.evaluate_bundle(
|
|
201
|
+
argparse.Namespace(
|
|
202
|
+
root=scene_dir,
|
|
203
|
+
registry=resolved_intake["registry"],
|
|
204
|
+
receipt=resolved_intake["receipt"],
|
|
205
|
+
controls=resolved_intake["controls"],
|
|
206
|
+
export_manifest=resolved_intake["export_manifest"],
|
|
207
|
+
strict=require_artifact_intake,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
issues.extend(
|
|
211
|
+
f"artifact intake: {item.get('code', 'issue')} — {item.get('message', 'contract violation')}"
|
|
212
|
+
for item in intake_result.get("errors", [])
|
|
213
|
+
)
|
|
214
|
+
if require_artifact_intake and not intake_result.get("ready"):
|
|
215
|
+
issues.append("artifact intake contract is not runtime-ready")
|
|
216
|
+
except (OSError, ValueError, AttributeError, KeyError, TypeError) as exc:
|
|
217
|
+
issues.append(f"artifact intake contract: {exc}")
|
|
218
|
+
|
|
141
219
|
checks = manifest.get("checks")
|
|
142
220
|
if not isinstance(checks, list) or not checks:
|
|
143
221
|
issues.append("manifest.checks must contain the Dev Lab quality checklist")
|
|
@@ -414,6 +492,9 @@ def main() -> int:
|
|
|
414
492
|
parser.add_argument("--require-visual-truth", action="store_true")
|
|
415
493
|
parser.add_argument("--require-asset-provenance", action="store_true")
|
|
416
494
|
parser.add_argument("--asset-provenance")
|
|
495
|
+
parser.add_argument("--require-asset-consistency", action="store_true")
|
|
496
|
+
parser.add_argument("--asset-consistency")
|
|
497
|
+
parser.add_argument("--require-artifact-intake", action="store_true")
|
|
417
498
|
parser.add_argument("--attestation")
|
|
418
499
|
parser.add_argument("--trust-policy")
|
|
419
500
|
args = parser.parse_args()
|
|
@@ -429,13 +510,14 @@ def main() -> int:
|
|
|
429
510
|
attestation_path = Path(args.attestation).resolve() if args.attestation else None
|
|
430
511
|
trust_policy_path = Path(args.trust_policy).resolve() if args.trust_policy else None
|
|
431
512
|
asset_provenance_path = Path(args.asset_provenance).resolve() if args.asset_provenance else None
|
|
513
|
+
asset_consistency_path = Path(args.asset_consistency).resolve() if args.asset_consistency else None
|
|
432
514
|
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 []
|
|
433
515
|
if not scenes:
|
|
434
516
|
print("QUALITY GATE: no scene outputs found")
|
|
435
517
|
return 0
|
|
436
518
|
failed = False
|
|
437
519
|
for scene_dir in scenes:
|
|
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)
|
|
520
|
+
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, args.require_asset_consistency, asset_consistency_path, args.require_artifact_intake)
|
|
439
521
|
if issues:
|
|
440
522
|
failed = True
|
|
441
523
|
print(f"REJECTED {scene_dir.name}:")
|
|
@@ -447,6 +529,10 @@ def main() -> int:
|
|
|
447
529
|
suffixes.append("visual-truth contract")
|
|
448
530
|
if args.require_asset_provenance:
|
|
449
531
|
suffixes.append("asset provenance production eligibility")
|
|
532
|
+
if args.require_asset_consistency:
|
|
533
|
+
suffixes.append("asset consistency contract")
|
|
534
|
+
if args.require_artifact_intake:
|
|
535
|
+
suffixes.append("runtime-ready artifact intake")
|
|
450
536
|
suffix = f" + {' + '.join(suffixes)}" if suffixes else ""
|
|
451
537
|
print(f"ACCEPTED {scene_dir.name}: context + spec + runtime snapshots + browser-review candidate + checklist{suffix}")
|
|
452
538
|
return 1 if failed else 0
|
package/scripts/report.py
CHANGED
|
@@ -63,6 +63,24 @@ def asset_provenance_module():
|
|
|
63
63
|
return module
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
def asset_consistency_module():
|
|
67
|
+
path = ROOT / "scripts" / "asset-consistency.py"
|
|
68
|
+
loader = importlib.util.spec_from_file_location("motionloom_asset_consistency", path)
|
|
69
|
+
module = importlib.util.module_from_spec(loader)
|
|
70
|
+
sys.modules[loader.name] = module
|
|
71
|
+
loader.loader.exec_module(module)
|
|
72
|
+
return module
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def artifact_intake_module():
|
|
76
|
+
path = ROOT / "scripts" / "artifact-intake.py"
|
|
77
|
+
loader = importlib.util.spec_from_file_location("motionloom_artifact_intake", path)
|
|
78
|
+
module = importlib.util.module_from_spec(loader)
|
|
79
|
+
sys.modules[loader.name] = module
|
|
80
|
+
loader.loader.exec_module(module)
|
|
81
|
+
return module
|
|
82
|
+
|
|
83
|
+
|
|
66
84
|
def asset_provenance_result(scene_manifest_path: Path, scene_manifest: dict, mode: str = "runtime") -> dict:
|
|
67
85
|
name = scene_manifest.get("asset_provenance")
|
|
68
86
|
if not name:
|
|
@@ -81,6 +99,44 @@ def asset_provenance_result(scene_manifest_path: Path, scene_manifest: dict, mod
|
|
|
81
99
|
return {"status": "fail", "errors": [f"asset provenance contract: {exc}"]}
|
|
82
100
|
|
|
83
101
|
|
|
102
|
+
def asset_consistency_result(scene_manifest_path: Path, scene_manifest: dict, strict: bool = False) -> dict:
|
|
103
|
+
name = scene_manifest.get("consistency_ref")
|
|
104
|
+
if not name:
|
|
105
|
+
return {"status": "not-run", "ready": False, "errors": []}
|
|
106
|
+
scene_dir = scene_manifest_path.parent.resolve()
|
|
107
|
+
consistency_path = (scene_dir / str(name)).resolve()
|
|
108
|
+
if scene_dir not in consistency_path.parents or not consistency_path.is_file():
|
|
109
|
+
return {"status": "fail", "ready": False, "errors": ["scene manifest consistency_ref points to a missing or unsafe artifact"]}
|
|
110
|
+
try:
|
|
111
|
+
document = read_json(consistency_path)
|
|
112
|
+
kind = scene_manifest.get("consistency_kind", "frame-geometry")
|
|
113
|
+
result = asset_consistency_module().run(kind, document, scene_dir, strict=strict)
|
|
114
|
+
result["status"] = "pass" if result.get("ready") else "fail"
|
|
115
|
+
return result
|
|
116
|
+
except (OSError, ValueError, AttributeError, KeyError, TypeError) as exc:
|
|
117
|
+
return {"status": "fail", "ready": False, "errors": [f"asset consistency contract: {exc}"]}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def artifact_intake_result(scene_manifest_path: Path, scene_manifest: dict, strict: bool = False) -> dict:
|
|
121
|
+
intake = scene_manifest.get("artifact_intake")
|
|
122
|
+
if not intake:
|
|
123
|
+
return {"status": "not-run", "ready": False, "errors": []}
|
|
124
|
+
required = ("registry", "receipt", "controls", "export_manifest")
|
|
125
|
+
scene_dir = scene_manifest_path.parent.resolve()
|
|
126
|
+
if not isinstance(intake, dict) or any(not isinstance(intake.get(key), str) for key in required):
|
|
127
|
+
return {"status": "fail", "ready": False, "errors": ["scene manifest artifact_intake is incomplete"]}
|
|
128
|
+
paths = {key: (scene_dir / intake[key]).resolve() for key in required}
|
|
129
|
+
if any(scene_dir not in path.parents or not path.is_file() for path in paths.values()):
|
|
130
|
+
return {"status": "fail", "ready": False, "errors": ["scene manifest artifact_intake points to a missing or unsafe artifact"]}
|
|
131
|
+
try:
|
|
132
|
+
result = artifact_intake_module().evaluate_bundle(
|
|
133
|
+
argparse.Namespace(root=scene_dir, registry=paths["registry"], receipt=paths["receipt"], controls=paths["controls"], export_manifest=paths["export_manifest"], strict=strict)
|
|
134
|
+
)
|
|
135
|
+
return result
|
|
136
|
+
except (OSError, ValueError, AttributeError, KeyError, TypeError) as exc:
|
|
137
|
+
return {"status": "fail", "ready": False, "errors": [f"artifact intake contract: {exc}"]}
|
|
138
|
+
|
|
139
|
+
|
|
84
140
|
def memory_summary() -> dict | None:
|
|
85
141
|
path = project_memory_path()
|
|
86
142
|
if not path.is_file():
|
|
@@ -475,6 +531,18 @@ def check_report(args: argparse.Namespace) -> int:
|
|
|
475
531
|
errors.append("ready-for-PR or confirmed task requires a passing asset provenance production check")
|
|
476
532
|
elif not provenance.get("summary", {}).get("production_eligible"):
|
|
477
533
|
errors.append("ready-for-PR or confirmed task requires asset provenance production_eligible")
|
|
534
|
+
consistency = asset_consistency_result(scene_manifest_path, scene_manifest, strict=state in {"ready_for_pr", "confirmed"})
|
|
535
|
+
if consistency.get("status") == "fail":
|
|
536
|
+
errors.extend(f"asset consistency: {error}" for error in consistency.get("errors", []))
|
|
537
|
+
if state in {"ready_for_pr", "confirmed"} and scene_manifest_path.is_file() and scene_manifest.get("consistency_ref"):
|
|
538
|
+
if consistency.get("status") != "pass" or not consistency.get("ready"):
|
|
539
|
+
errors.append("ready-for-PR or confirmed task requires a passing asset consistency contract")
|
|
540
|
+
intake = artifact_intake_result(scene_manifest_path, scene_manifest, strict=state in {"ready_for_pr", "confirmed"})
|
|
541
|
+
if intake.get("status") == "fail":
|
|
542
|
+
errors.extend(f"artifact intake: {error}" for error in intake.get("errors", []))
|
|
543
|
+
if state in {"ready_for_pr", "confirmed"} and scene_manifest_path.is_file() and scene_manifest.get("artifact_intake"):
|
|
544
|
+
if intake.get("status") != "pass" or not intake.get("ready"):
|
|
545
|
+
errors.append("ready-for-PR or confirmed task requires a runtime-ready artifact intake contract")
|
|
478
546
|
if state in {"validated", "ready_for_pr", "confirmed"}:
|
|
479
547
|
quality = read_json(task_dir / "quality-report.json")
|
|
480
548
|
if quality.get("status") != "pass":
|
|
@@ -576,6 +644,18 @@ def render(args: argparse.Namespace) -> int:
|
|
|
576
644
|
scene_manifest,
|
|
577
645
|
"production" if task.get("state") in {"ready_for_pr", "confirmed"} else "runtime",
|
|
578
646
|
)
|
|
647
|
+
consistency = asset_consistency_result(
|
|
648
|
+
ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json",
|
|
649
|
+
scene_manifest,
|
|
650
|
+
strict=task.get("state") in {"ready_for_pr", "confirmed"},
|
|
651
|
+
)
|
|
652
|
+
intake = artifact_intake_result(
|
|
653
|
+
ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json",
|
|
654
|
+
scene_manifest,
|
|
655
|
+
strict=task.get("state") in {"ready_for_pr", "confirmed"},
|
|
656
|
+
)
|
|
657
|
+
intake_adapter = intake.get("adapter") or {}
|
|
658
|
+
intake_evidence = intake.get("evidence") or {}
|
|
579
659
|
lines = [
|
|
580
660
|
f"# Animation Task Report — {task.get('task_id', task_dir.name)}",
|
|
581
661
|
"",
|
|
@@ -609,6 +689,11 @@ def render(args: argparse.Namespace) -> int:
|
|
|
609
689
|
"## Asset provenance",
|
|
610
690
|
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
691
|
f"- Production eligible: **{provenance.get('summary', {}).get('production_eligible', False)}**; production approved: **{provenance.get('summary', {}).get('production_approved', False)}**; errors: **{len(provenance.get('errors', []))}**",
|
|
692
|
+
"## Asset consistency",
|
|
693
|
+
f"- Status: **{consistency.get('status', 'not-run')}**; contract: **{consistency.get('contract', scene_manifest.get('consistency_kind', 'not-declared'))}**; ready: **{consistency.get('ready', False)}**; errors: **{len(consistency.get('errors', []))}**; warnings: **{len(consistency.get('warnings', []))}**",
|
|
694
|
+
"## Artifact Intake",
|
|
695
|
+
f"- Status: **{intake.get('status', 'not-run')}**; adapter: **{intake_adapter.get('adapter_id', 'not-declared')}**; adapter status: **{intake_adapter.get('status', 'not-declared')}**; ready: **{intake.get('ready', False)}**; production eligible: **{intake.get('production_eligible', False)}**; production approved: **{intake.get('production_approved', False)}**",
|
|
696
|
+
f"- Receipt: `{intake_evidence.get('receipt', '')}`; controls: `{intake_evidence.get('controls', '')}`; export: `{intake_evidence.get('export_manifest', '')}`; errors: **{len(intake.get('errors', []))}**; warnings: **{len(intake.get('warnings', []))}**",
|
|
612
697
|
"## Semantic motion lint",
|
|
613
698
|
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)}**",
|
|
614
699
|
md_table(lint.get("findings", []), [("Rule", "rule_id"), ("Severity", "severity"), ("Confidence", "confidence"), ("Message", "message"), ("Basis", "basis")]),
|