motionloom 2.2.0 → 2.4.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 +48 -1
- package/README.md +123 -17
- package/ROADMAP.md +8 -3
- package/SKILL.md +34 -3
- package/agent-card.json +48 -5
- package/agent-surfaces.json +8 -1
- package/artifact-adapter-registry.json +53 -0
- package/bin/motionloom.mjs +41 -4
- package/docs/AGENT-INTEGRATION.md +15 -2
- package/docs/CHECKLIST.md +21 -0
- package/docs/STATUS.md +2 -2
- package/docs/audits/data/deep-stress-latest.json +65 -65
- package/docs/releases/2.3.0.md +33 -0
- package/docs/releases/2.4.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/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/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 +65 -4
- package/references/agent-interoperability.md +11 -0
- package/references/browser-review-contract.md +7 -1
- package/references/intelligence-core.md +29 -2
- package/rig-adapter-registry.json +39 -0
- package/schemas/action-set.schema.json +42 -0
- package/schemas/agent-surfaces.schema.json +1 -1
- package/schemas/artifact-adapter-registry.schema.json +16 -0
- package/schemas/asset-identity.schema.json +117 -0
- package/schemas/asset-provenance.schema.json +183 -0
- 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 +19 -0
- package/scripts/artifact-intake.py +408 -0
- package/scripts/asset-consistency.py +517 -0
- package/scripts/asset-provenance.py +395 -0
- package/scripts/build-ai-pilot.py +504 -0
- package/scripts/docs-audit.py +14 -2
- package/scripts/isolate-alpha-background.py +147 -0
- package/scripts/pr.py +2 -0
- package/scripts/quality-gate.py +127 -3
- package/scripts/report.py +135 -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 +500 -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/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 +131 -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
|
@@ -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/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,32 @@ 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
|
+
|
|
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
|
+
|
|
58
84
|
def _json(path: Path):
|
|
59
85
|
try:
|
|
60
86
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
@@ -74,7 +100,7 @@ def _telemetry_bundle_sha256(task_dir: Path) -> str:
|
|
|
74
100
|
return hashlib.sha256(json.dumps(entries, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
|
75
101
|
|
|
76
102
|
|
|
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]:
|
|
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]:
|
|
78
104
|
issues = []
|
|
79
105
|
manifest_path = scene_dir / "manifest.json"
|
|
80
106
|
spec_path = scene_dir / "motion-spec.json"
|
|
@@ -108,6 +134,88 @@ def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = F
|
|
|
108
134
|
if not (spec.get("accessibility") or {}).get("reduced_motion"):
|
|
109
135
|
issues.append("motion spec has no reduced-motion policy")
|
|
110
136
|
|
|
137
|
+
asset_provenance_name = manifest.get("asset_provenance")
|
|
138
|
+
if require_asset_provenance and not isinstance(asset_provenance_name, str):
|
|
139
|
+
issues.append("asset provenance gate requires manifest.asset_provenance")
|
|
140
|
+
if asset_provenance_name or asset_provenance_path:
|
|
141
|
+
resolved_asset_provenance = asset_provenance_path or (scene_dir / str(asset_provenance_name)).resolve()
|
|
142
|
+
if scene_dir.resolve() not in resolved_asset_provenance.parents or not resolved_asset_provenance.is_file():
|
|
143
|
+
issues.append("manifest.asset_provenance must point to an existing file inside the scene directory")
|
|
144
|
+
else:
|
|
145
|
+
try:
|
|
146
|
+
asset_module = _load_asset_provenance()
|
|
147
|
+
asset_result = asset_module.evaluate(
|
|
148
|
+
resolved_asset_provenance,
|
|
149
|
+
base=scene_dir,
|
|
150
|
+
mode="production" if require_asset_provenance else "runtime",
|
|
151
|
+
manifest=manifest,
|
|
152
|
+
)
|
|
153
|
+
issues.extend(f"asset provenance: {issue}" for issue in asset_result.get("errors", []))
|
|
154
|
+
if require_asset_provenance and not asset_result.get("summary", {}).get("production_eligible"):
|
|
155
|
+
issues.append("asset provenance is not production_eligible; human approval remains separate")
|
|
156
|
+
except (OSError, ValueError, AttributeError) as exc:
|
|
157
|
+
issues.append(f"asset provenance contract: {exc}")
|
|
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
|
+
|
|
111
219
|
checks = manifest.get("checks")
|
|
112
220
|
if not isinstance(checks, list) or not checks:
|
|
113
221
|
issues.append("manifest.checks must contain the Dev Lab quality checklist")
|
|
@@ -382,6 +490,11 @@ def main() -> int:
|
|
|
382
490
|
parser.add_argument("--require-telemetry", action="store_true")
|
|
383
491
|
parser.add_argument("--require-attestation", action="store_true")
|
|
384
492
|
parser.add_argument("--require-visual-truth", action="store_true")
|
|
493
|
+
parser.add_argument("--require-asset-provenance", action="store_true")
|
|
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")
|
|
385
498
|
parser.add_argument("--attestation")
|
|
386
499
|
parser.add_argument("--trust-policy")
|
|
387
500
|
args = parser.parse_args()
|
|
@@ -396,20 +509,31 @@ def main() -> int:
|
|
|
396
509
|
task_dir = Path(args.task_dir).resolve() if args.task_dir else None
|
|
397
510
|
attestation_path = Path(args.attestation).resolve() if args.attestation else None
|
|
398
511
|
trust_policy_path = Path(args.trust_policy).resolve() if args.trust_policy else None
|
|
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
|
|
399
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 []
|
|
400
515
|
if not scenes:
|
|
401
516
|
print("QUALITY GATE: no scene outputs found")
|
|
402
517
|
return 0
|
|
403
518
|
failed = False
|
|
404
519
|
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)
|
|
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)
|
|
406
521
|
if issues:
|
|
407
522
|
failed = True
|
|
408
523
|
print(f"REJECTED {scene_dir.name}:")
|
|
409
524
|
for issue in issues:
|
|
410
525
|
print(f" - {issue}")
|
|
411
526
|
else:
|
|
412
|
-
|
|
527
|
+
suffixes = []
|
|
528
|
+
if args.require_visual_truth:
|
|
529
|
+
suffixes.append("visual-truth contract")
|
|
530
|
+
if args.require_asset_provenance:
|
|
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")
|
|
536
|
+
suffix = f" + {' + '.join(suffixes)}" if suffixes else ""
|
|
413
537
|
print(f"ACCEPTED {scene_dir.name}: context + spec + runtime snapshots + browser-review candidate + checklist{suffix}")
|
|
414
538
|
return 1 if failed else 0
|
|
415
539
|
|
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,88 @@ 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_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
|
+
|
|
84
|
+
def asset_provenance_result(scene_manifest_path: Path, scene_manifest: dict, mode: str = "runtime") -> dict:
|
|
85
|
+
name = scene_manifest.get("asset_provenance")
|
|
86
|
+
if not name:
|
|
87
|
+
return {"status": "not-run", "errors": ["scene manifest has no asset_provenance"]}
|
|
88
|
+
provenance_path = (scene_manifest_path.parent / str(name)).resolve()
|
|
89
|
+
if scene_manifest_path.parent.resolve() not in provenance_path.parents or not provenance_path.is_file():
|
|
90
|
+
return {"status": "fail", "errors": ["scene manifest asset_provenance points to a missing or unsafe artifact"]}
|
|
91
|
+
try:
|
|
92
|
+
return asset_provenance_module().evaluate(
|
|
93
|
+
provenance_path,
|
|
94
|
+
base=scene_manifest_path.parent,
|
|
95
|
+
mode=mode,
|
|
96
|
+
manifest=scene_manifest,
|
|
97
|
+
)
|
|
98
|
+
except (OSError, ValueError, AttributeError) as exc:
|
|
99
|
+
return {"status": "fail", "errors": [f"asset provenance contract: {exc}"]}
|
|
100
|
+
|
|
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
|
+
|
|
57
140
|
def memory_summary() -> dict | None:
|
|
58
141
|
path = project_memory_path()
|
|
59
142
|
if not path.is_file():
|
|
@@ -433,6 +516,33 @@ def check_report(args: argparse.Namespace) -> int:
|
|
|
433
516
|
visual_truth_path = scene_manifest_path.parent / str(visual_truth_name)
|
|
434
517
|
if not visual_truth_path.is_file():
|
|
435
518
|
errors.append("scene manifest visual_truth points to a missing artifact")
|
|
519
|
+
provenance_mode = "production" if state in {"ready_for_pr", "confirmed"} else "runtime"
|
|
520
|
+
provenance = asset_provenance_result(scene_manifest_path, scene_manifest, provenance_mode)
|
|
521
|
+
if provenance.get("status") == "fail":
|
|
522
|
+
errors.extend(f"asset provenance: {error}" for error in provenance.get("errors", []))
|
|
523
|
+
# Legacy report-contract fixtures may exercise lifecycle/report behavior
|
|
524
|
+
# without materializing a scene manifest. Do not invent provenance for
|
|
525
|
+
# those synthetic tasks. Once a real scene manifest exists, readiness is
|
|
526
|
+
# fail-closed and its asset_provenance reference is mandatory for PR
|
|
527
|
+
# states; the production quality gate remains independently strict when
|
|
528
|
+
# --require-asset-provenance is supplied.
|
|
529
|
+
if state in {"ready_for_pr", "confirmed"} and scene_manifest_path.is_file():
|
|
530
|
+
if provenance.get("status") != "pass":
|
|
531
|
+
errors.append("ready-for-PR or confirmed task requires a passing asset provenance production check")
|
|
532
|
+
elif not provenance.get("summary", {}).get("production_eligible"):
|
|
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")
|
|
436
546
|
if state in {"validated", "ready_for_pr", "confirmed"}:
|
|
437
547
|
quality = read_json(task_dir / "quality-report.json")
|
|
438
548
|
if quality.get("status") != "pass":
|
|
@@ -529,6 +639,23 @@ def render(args: argparse.Namespace) -> int:
|
|
|
529
639
|
ROOT / "src" / "output" / str(task.get("scene", "")) / str(scene_manifest.get("visual_truth", "")),
|
|
530
640
|
{},
|
|
531
641
|
) if scene_manifest.get("visual_truth") else {}
|
|
642
|
+
provenance = asset_provenance_result(
|
|
643
|
+
ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json",
|
|
644
|
+
scene_manifest,
|
|
645
|
+
"production" if task.get("state") in {"ready_for_pr", "confirmed"} else "runtime",
|
|
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 {}
|
|
532
659
|
lines = [
|
|
533
660
|
f"# Animation Task Report — {task.get('task_id', task_dir.name)}",
|
|
534
661
|
"",
|
|
@@ -559,6 +686,14 @@ def render(args: argparse.Namespace) -> int:
|
|
|
559
686
|
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
687
|
f"- Baseline: `{visual_truth.get('frames', {}).get('baseline', {}).get('path', '')}`; candidate: `{visual_truth.get('frames', {}).get('candidate', {}).get('path', '')}`",
|
|
561
688
|
f"- Changed pixels: **{visual_truth.get('comparison', {}).get('changed_pixels', 'not-run')}**; changed regions: **{len(visual_truth.get('comparison', {}).get('regions', []))}**",
|
|
689
|
+
"## Asset provenance",
|
|
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')}**",
|
|
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', []))}**",
|
|
562
697
|
"## Semantic motion lint",
|
|
563
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)}**",
|
|
564
699
|
md_table(lint.get("findings", []), [("Rule", "rule_id"), ("Severity", "severity"), ("Confidence", "confidence"), ("Message", "message"), ("Basis", "basis")]),
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Resolve one safe artifact task bundle bound to a scene identity.
|
|
3
|
+
|
|
4
|
+
The resolver intentionally does not infer a task path from a scene slug. A
|
|
5
|
+
bundle is eligible only when its direct ``artifacts/<task-id>/task.json``
|
|
6
|
+
declares the requested scene and remains inside the repository artifacts root.
|
|
7
|
+
Multiple matching bundles are rejected rather than ranked implicitly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SAFE_SCENE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_json(path: Path) -> dict:
|
|
23
|
+
try:
|
|
24
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
25
|
+
except (OSError, json.JSONDecodeError):
|
|
26
|
+
return {}
|
|
27
|
+
return data if isinstance(data, dict) else {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def has_symlink_component(path: Path, root: Path) -> bool:
|
|
31
|
+
current = path
|
|
32
|
+
while True:
|
|
33
|
+
if current.is_symlink():
|
|
34
|
+
return True
|
|
35
|
+
if current == root:
|
|
36
|
+
return False
|
|
37
|
+
if current == current.parent:
|
|
38
|
+
return True
|
|
39
|
+
current = current.parent
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_task_dirs(root: Path, scene: str) -> list[Path]:
|
|
43
|
+
artifacts = root / "artifacts"
|
|
44
|
+
if not artifacts.is_dir() or artifacts.is_symlink():
|
|
45
|
+
return []
|
|
46
|
+
resolved_artifacts = artifacts.resolve()
|
|
47
|
+
matches: list[Path] = []
|
|
48
|
+
for task_path in sorted(artifacts.glob("*/task.json")):
|
|
49
|
+
if has_symlink_component(task_path, root):
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
if not task_path.resolve().is_relative_to(resolved_artifacts):
|
|
53
|
+
continue
|
|
54
|
+
except (OSError, RuntimeError):
|
|
55
|
+
continue
|
|
56
|
+
if read_json(task_path).get("scene") == scene:
|
|
57
|
+
matches.append(task_path.parent)
|
|
58
|
+
return matches
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main() -> int:
|
|
62
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
63
|
+
parser.add_argument("--scene", required=True)
|
|
64
|
+
parser.add_argument("--root", default=".")
|
|
65
|
+
args = parser.parse_args()
|
|
66
|
+
if not SAFE_SCENE.fullmatch(args.scene) or args.scene in {".", ".."}:
|
|
67
|
+
print("FAIL: unsafe scene identifier", file=sys.stderr)
|
|
68
|
+
return 2
|
|
69
|
+
|
|
70
|
+
root = Path(args.root).resolve()
|
|
71
|
+
matches = resolve_task_dirs(root, args.scene)
|
|
72
|
+
if not matches:
|
|
73
|
+
print(f"FAIL: no safe task bundle bound to scene: {args.scene}", file=sys.stderr)
|
|
74
|
+
return 3
|
|
75
|
+
if len(matches) != 1:
|
|
76
|
+
joined = ", ".join(str(path.relative_to(root)) for path in matches)
|
|
77
|
+
print(f"FAIL: ambiguous task bundles bound to scene {args.scene}: {joined}", file=sys.stderr)
|
|
78
|
+
return 4
|
|
79
|
+
print(matches[0].relative_to(root).as_posix())
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
raise SystemExit(main())
|
package/scripts/review-hook.py
CHANGED
|
@@ -12,7 +12,7 @@ import subprocess
|
|
|
12
12
|
import sys
|
|
13
13
|
from datetime import datetime, timedelta, timezone
|
|
14
14
|
from pathlib import Path
|
|
15
|
-
from urllib.parse import urlencode
|
|
15
|
+
from urllib.parse import urlencode, urlsplit, urlunsplit
|
|
16
16
|
|
|
17
17
|
ROOT = Path(__file__).resolve().parents[1]
|
|
18
18
|
SAFE_SCENE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
@@ -42,6 +42,29 @@ def candidate_id(task_id: str, scene: str, context_hash: str, source_hash: str,
|
|
|
42
42
|
return hashlib.sha256(raw).hexdigest()[:20]
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
def review_urls(lab_url: str, scene: str, task_id: str, candidate: str) -> tuple[str, str, str]:
|
|
46
|
+
"""Build an exact review route while anchoring artifact paths at its origin.
|
|
47
|
+
|
|
48
|
+
`--lab-url` is the actual browser route, e.g. `https://host/lab` for the
|
|
49
|
+
React Dev Lab or `http://127.0.0.1:3300/` for the bundled static lab.
|
|
50
|
+
Artifact and task paths intentionally remain same-origin root paths.
|
|
51
|
+
"""
|
|
52
|
+
parsed = urlsplit(lab_url.strip())
|
|
53
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.query or parsed.fragment:
|
|
54
|
+
raise ValueError("lab-url must be an absolute http(s) Dev Lab route without query or fragment")
|
|
55
|
+
origin = urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
|
56
|
+
review_path = parsed.path.rstrip("/") or "/"
|
|
57
|
+
review_base = f"{origin}{review_path}" if review_path != "/" else origin
|
|
58
|
+
query = urlencode({
|
|
59
|
+
"scene": scene,
|
|
60
|
+
"task_id": task_id,
|
|
61
|
+
"candidate_id": candidate,
|
|
62
|
+
"artifact_base": f"{origin}/scenes/{scene}",
|
|
63
|
+
"task_base": f"{origin}/tasks/{task_id}",
|
|
64
|
+
})
|
|
65
|
+
return f"{review_base}/?{query}" if review_path != "/" else f"{origin}/?{query}", f"{origin}/scenes/{scene}", f"{origin}/tasks/{task_id}"
|
|
66
|
+
|
|
67
|
+
|
|
45
68
|
def paths(task_dir: Path, task: dict) -> tuple[Path, Path, Path, Path]:
|
|
46
69
|
scene = task.get("scene", "")
|
|
47
70
|
if not scene or scene in {".", ".."} or not SAFE_SCENE.fullmatch(scene):
|
|
@@ -79,9 +102,8 @@ def prepare(args: argparse.Namespace) -> int:
|
|
|
79
102
|
source_hash = sha256(source_path)
|
|
80
103
|
render_hash = sha256(render_meta)
|
|
81
104
|
cid = candidate_id(task["task_id"], scene, context_hash, source_hash, render_hash)
|
|
82
|
-
base = args.lab_url.rstrip("/")
|
|
83
105
|
task_id = task["task_id"]
|
|
84
|
-
url =
|
|
106
|
+
url, artifact_base, task_base = review_urls(args.lab_url, scene, task_id, cid)
|
|
85
107
|
candidate = {
|
|
86
108
|
"schema_version": "1.0",
|
|
87
109
|
"candidate_id": cid,
|
|
@@ -120,7 +142,7 @@ def prepare(args: argparse.Namespace) -> int:
|
|
|
120
142
|
handoff = read_json(handoff_path)
|
|
121
143
|
if memory_path.is_file():
|
|
122
144
|
shutil.copy2(memory_path, task_dir / "project-memory.json")
|
|
123
|
-
handoff.update({"state": "review_required", "to_agent": "browser-review-agent", "summary": "Open the exact rendered candidate in the internal Dev Lab and obtain user approval before PR.", "next_actions": [{"action": "Open internal Dev Lab candidate", "kind": "browser_review", "agent": "browser-review-agent", "skill": "browser-review", "url": url, "candidate_id": cid, "requires_user_approval": True, "evidence_needed": ["review.json"], "output_artifacts": ["review.json"]}], "required_artifacts": sorted(set(handoff.get("required_artifacts", []) + ["browser-review.json", "review.json", "semantic-lint-benchmark.json", "evidence-verifier-report.json", "runtime-adapters/runtime-evidence.json"] + (["project-memory.json"] if memory_path.is_file() else [])))})
|
|
145
|
+
handoff.update({"state": "review_required", "to_agent": "browser-review-agent", "summary": "Open the exact rendered candidate in the internal Dev Lab and obtain user approval before PR.", "next_actions": [{"action": "Open internal Dev Lab candidate", "kind": "browser_review", "agent": "browser-review-agent", "skill": "browser-review", "url": url, "artifact_base": artifact_base, "task_base": task_base, "candidate_id": cid, "requires_user_approval": True, "evidence_needed": ["review.json"], "output_artifacts": ["review.json"]}], "required_artifacts": sorted(set(handoff.get("required_artifacts", []) + ["browser-review.json", "review.json", "semantic-lint-benchmark.json", "evidence-verifier-report.json", "runtime-adapters/runtime-evidence.json"] + (["project-memory.json"] if memory_path.is_file() else [])))})
|
|
124
146
|
handoff_path.write_text(json.dumps(handoff, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
125
147
|
subprocess.run([sys.executable, str(ROOT / "scripts/devlab.py"), scene, "--prepare-only", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
|
|
126
148
|
subprocess.run([sys.executable, str(ROOT / "scripts/report.py"), "collect", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
|