motionloom 2.0.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.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -0
  3. package/SKILL.md +122 -0
  4. package/agent-card.json +161 -0
  5. package/assets/library/ATTRIBUTION.md +6 -0
  6. package/assets/library/README.md +20 -0
  7. package/assets/library/avatar-base.svg +19 -0
  8. package/assets/library/error-alert.json +1 -0
  9. package/assets/library/rive/ATTRIBUTION.md +12 -0
  10. package/assets/library/rive/state-machine-test.riv +0 -0
  11. package/assets/library/success-check.json +1 -0
  12. package/bin/motionloom.mjs +81 -0
  13. package/docs/BROWSER-REVIEW-E2E.md +78 -0
  14. package/docs/CATEGORIES.md +16 -0
  15. package/docs/CHECKLIST.md +31 -0
  16. package/docs/DEEP-AUDIT-WORKING-NOTES.md +22 -0
  17. package/docs/FRAMEWORK-SELECTION.md +26 -0
  18. package/docs/PROJECT-MANIFEST.md +37 -0
  19. package/docs/ROADMAP-INTELLIGENCE.md +224 -0
  20. package/docs/audits/1.10.0-attestation-research-notes.md +19 -0
  21. package/docs/audits/1.8.0-trust-boundary-hardening.md +56 -0
  22. package/docs/audits/1.9.0-evidence-interoperability-threat-model.md +37 -0
  23. package/docs/audits/2.0.0-attestation-acceptance.md +30 -0
  24. package/docs/releases/1.5.0.md +25 -0
  25. package/docs/releases/1.6.0.md +27 -0
  26. package/docs/releases/1.7.0.md +23 -0
  27. package/docs/releases/1.8.0.md +23 -0
  28. package/docs/releases/1.9.0.md +21 -0
  29. package/docs/releases/2.0.0.md +21 -0
  30. package/docs/releases/npm-publish-from-workstation.md +88 -0
  31. package/docs/research/AGENT-PROTOCOL-FINDINGS.md +43 -0
  32. package/examples/report-demo/REPORT.md +50 -0
  33. package/examples/report-demo/artifact-manifest.json +25 -0
  34. package/examples/report-demo/decision-log.jsonl +0 -0
  35. package/examples/report-demo/execution-report.json +70 -0
  36. package/examples/report-demo/handoff.json +22 -0
  37. package/examples/report-demo/issue-register.json +5 -0
  38. package/examples/report-demo/task.json +13 -0
  39. package/package.json +95 -0
  40. package/project-context.example.json +26 -0
  41. package/references/browser-review-contract.md +32 -0
  42. package/references/dotlottie-source-notes.md +21 -0
  43. package/references/intelligence-core.md +98 -0
  44. package/references/reporting-contract.md +38 -0
  45. package/references/runtime-capability.md +12 -0
  46. package/references/signed-attestation.md +31 -0
  47. package/schemas/artifact-manifest.schema.json +22 -0
  48. package/schemas/browser-review-candidate.schema.json +24 -0
  49. package/schemas/capability-registry.schema.json +61 -0
  50. package/schemas/continuity-report.schema.json +52 -0
  51. package/schemas/evidence-verifier-report.schema.json +35 -0
  52. package/schemas/execution-report.schema.json +35 -0
  53. package/schemas/fix-plan.schema.json +44 -0
  54. package/schemas/handoff.schema.json +19 -0
  55. package/schemas/motion-ir.schema.json +74 -0
  56. package/schemas/project-graph.schema.json +70 -0
  57. package/schemas/provenance.schema.json +76 -0
  58. package/schemas/runtime-evidence.schema.json +48 -0
  59. package/schemas/runtime-telemetry.schema.json +50 -0
  60. package/schemas/scene-manifest.schema.json +45 -0
  61. package/schemas/semantic-benchmark.schema.json +26 -0
  62. package/schemas/semantic-lint-report.schema.json +48 -0
  63. package/schemas/signed-attestation.schema.json +95 -0
  64. package/schemas/task.schema.json +39 -0
  65. package/schemas/trust-policy.schema.json +53 -0
  66. package/scripts/analyze.sh +13 -0
  67. package/scripts/attestation-keygen.py +63 -0
  68. package/scripts/attestation-verifier.py +178 -0
  69. package/scripts/attestation.py +288 -0
  70. package/scripts/capture-runtime-telemetry.sh +37 -0
  71. package/scripts/devlab.sh +77 -0
  72. package/scripts/eval-intelligence.py +377 -0
  73. package/scripts/evidence-verifier.py +222 -0
  74. package/scripts/fetch-library.sh +57 -0
  75. package/scripts/intelligence.py +1543 -0
  76. package/scripts/manifest.py +61 -0
  77. package/scripts/pr.sh +103 -0
  78. package/scripts/quality-gate.py +378 -0
  79. package/scripts/render-node.mjs +53 -0
  80. package/scripts/render.sh +37 -0
  81. package/scripts/report-contract.py +181 -0
  82. package/scripts/report.py +588 -0
  83. package/scripts/review-hook.py +199 -0
  84. package/scripts/runtime-adapters.mjs +187 -0
  85. package/scripts/skill-doctor.py +150 -0
  86. package/scripts/to-dotlottie.mjs +99 -0
  87. package/scripts/to-dotlottie.sh +25 -0
  88. package/scripts/validate-lottie.py +102 -0
  89. package/src/core/analyzer.py +226 -0
  90. package/src/core/snapshot.py +124 -0
  91. package/src/core/spec.py +240 -0
  92. package/src/output/browser-review-smoke/animation.json +57 -0
  93. package/src/output/browser-review-smoke/browser-review.json +21 -0
  94. package/src/output/browser-review-smoke/manifest.json +22 -0
  95. package/src/output/browser-review-smoke/motion-spec.json +28 -0
  96. package/src/output/browser-review-smoke/snapshot/.render-meta.json +10 -0
  97. package/src/output/browser-review-smoke/snapshot/frame-00.png +0 -0
  98. package/src/output/browser-review-smoke/snapshot/frame-100.png +0 -0
  99. package/src/output/browser-review-smoke/snapshot/frame-50.png +0 -0
  100. package/src/rig/README.md +35 -0
  101. package/src/rig/cutout_rig.py +211 -0
  102. package/templates/framer-motion/ui-micro.tsx +50 -0
  103. package/templates/gsap/scroll-scene.js +54 -0
  104. package/templates/lottie/README.md +21 -0
  105. package/templates/lottie/react-component.tsx +82 -0
  106. package/templates/lottie/scaffold/animation.json +57 -0
  107. package/templates/lottie/scaffold/character-rig.svg +19 -0
  108. package/templates/lottie/vanilla.js +68 -0
  109. package/templates/rive/README.md +36 -0
  110. package/tests/evals/intelligence-cases.json +131 -0
  111. package/tests/scripts/run_tests.py +843 -0
  112. package/tests/scripts/test_attestation.py +172 -0
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env python3
2
+ """Create or refresh the required source_binding in a scene manifest."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ from pathlib import Path
10
+
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+ KINDS = {"project", "library", "generated", "fixture", "remote"}
14
+
15
+
16
+ def main() -> int:
17
+ parser = argparse.ArgumentParser()
18
+ sub = parser.add_subparsers(dest="command", required=True)
19
+ bind = sub.add_parser("bind-source")
20
+ bind.add_argument("--scene", required=True)
21
+ bind.add_argument("--source", required=True)
22
+ bind.add_argument("--kind", required=True, choices=sorted(KINDS))
23
+ bind.add_argument("--authority", required=True)
24
+ bind.add_argument("--license", required=True)
25
+ bind.add_argument("--source-url")
26
+ bind.add_argument("--attribution", default="")
27
+ bind.add_argument("--manifest", default="manifest.json")
28
+ args = parser.parse_args()
29
+
30
+ scene_dir = (ROOT / "src" / "output" / args.scene).resolve()
31
+ manifest_path = scene_dir / args.manifest
32
+ source_path = (scene_dir / args.source).resolve()
33
+ if not scene_dir.is_dir():
34
+ raise SystemExit(f"scene directory not found: {scene_dir}")
35
+ if not manifest_path.is_file():
36
+ raise SystemExit(f"manifest not found: {manifest_path}")
37
+ if not source_path.is_file() or scene_dir not in source_path.parents:
38
+ raise SystemExit("source must be an existing file inside the scene directory")
39
+
40
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
41
+ relative_source = source_path.relative_to(scene_dir).as_posix()
42
+ manifest["file"] = relative_source
43
+ binding = {
44
+ "kind": args.kind,
45
+ "source_path": relative_source,
46
+ "authority": args.authority,
47
+ "license": args.license,
48
+ "sha256": hashlib.sha256(source_path.read_bytes()).hexdigest(),
49
+ }
50
+ if args.source_url:
51
+ binding["source_url"] = args.source_url
52
+ if args.attribution:
53
+ binding["attribution"] = args.attribution
54
+ manifest["source_binding"] = binding
55
+ manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
56
+ print(json.dumps({"manifest": str(manifest_path), "source_binding": binding}, indent=2, ensure_ascii=False))
57
+ return 0
58
+
59
+
60
+ if __name__ == "__main__":
61
+ raise SystemExit(main())
package/scripts/pr.sh ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # pr.sh — Step 6 of the pipeline: confirm-into-PR.
3
+ # Commits the rendered scene (animation file, manifest, snapshots, signed
4
+ # spec) to a fix/<scene> branch and opens the pull request via the gh CLI.
5
+ #
6
+ # Usage: bash scripts/pr.sh <scene> [title]
7
+ set -euo pipefail
8
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ REPO="$(dirname "$SCRIPT_DIR")"
10
+ SCENE="${1:?usage: bash scripts/pr.sh <scene> [title]}"
11
+ TITLE="${2:-"animation: scene '$SCENE' (verified in Dev Lab)"}"
12
+ SCENE_DIR="$REPO/src/output/$SCENE"
13
+ CONTEXT_PATH="${CONTEXT_PATH:-$REPO/project-context.json}"
14
+ TASK_DIR="${TASK_DIR:-}"
15
+
16
+ cd "$REPO"
17
+
18
+ if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
19
+ echo "error: scripts/pr.sh must run inside a Git clone" >&2
20
+ exit 1
21
+ fi
22
+
23
+ if [ ! -d "$SCENE_DIR" ]; then
24
+ echo "error: scene directory not found: $SCENE_DIR"
25
+ exit 1
26
+ fi
27
+
28
+ if [[ ! "$SCENE" =~ ^[A-Za-z0-9._-]+$ || "$SCENE" == "." || "$SCENE" == ".." ]]; then
29
+ echo "error: scene id contains unsafe branch/path characters: $SCENE" >&2
30
+ exit 1
31
+ fi
32
+
33
+ echo "== running context-bound quality gate =="
34
+ QUALITY_ARGS=(--scene "$SCENE" --context "$CONTEXT_PATH" --require-browser-review)
35
+ if [ -n "$TASK_DIR" ]; then
36
+ QUALITY_ARGS+=(--task-dir "$TASK_DIR")
37
+ else
38
+ echo "error: TASK_DIR is required; browser Agent must persist review.json before PR" >&2
39
+ exit 1
40
+ fi
41
+
42
+ TASK_DIR_ABS="$(cd "$TASK_DIR" && pwd -P)"
43
+ case "$TASK_DIR_ABS" in
44
+ "$REPO"/*) ;;
45
+ *) echo "error: TASK_DIR must be inside the repository so evidence can be staged" >&2; exit 1 ;;
46
+ esac
47
+ TASK_SCENE="$(python3 - "$TASK_DIR_ABS/task.json" <<'PY'
48
+ import json, sys
49
+ from pathlib import Path
50
+ data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
51
+ print(data.get("scene", ""))
52
+ PY
53
+ )"
54
+ if [ "$TASK_SCENE" != "$SCENE" ]; then
55
+ echo "error: TASK_DIR task.scene does not match requested scene" >&2
56
+ exit 1
57
+ fi
58
+ python3 "$REPO/scripts/quality-gate.py" "${QUALITY_ARGS[@]}"
59
+ python3 "$REPO/scripts/review-hook.py" validate --task-dir "$TASK_DIR" --require-approved
60
+ python3 "$REPO/scripts/report.py" check --task-dir "$TASK_DIR"
61
+
62
+ BRANCH="fix/$SCENE"
63
+ git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
64
+ git add "src/output/$SCENE"
65
+ TASK_REL="${TASK_DIR_ABS#"$REPO/"}"
66
+ git add "$TASK_REL"
67
+ if git diff --cached --quiet; then
68
+ echo "error: no staged scene changes to commit" >&2
69
+ exit 1
70
+ fi
71
+ git commit -m "feat(animation): scene '$SCENE' — proven in Dev Lab
72
+
73
+ - motion-spec signed (see src/output/$SCENE/motion-spec.json)
74
+ - snapshot frames: 0/50/100% in src/output/$SCENE/snapshot/
75
+ - context-bound quality gate: passed
76
+ - brand tokens bound from $CONTEXT_PATH"
77
+
78
+ if [ "${OPEN_PR:-0}" != "1" ]; then
79
+ echo "== committed to $BRANCH — OPEN_PR=0, push/open PR manually =="
80
+ exit 0
81
+ fi
82
+
83
+ if ! command -v gh &>/dev/null; then
84
+ echo "== committed to $BRANCH — install gh CLI to open the PR =="
85
+ echo " git push origin $BRANCH"
86
+ exit 0
87
+ fi
88
+
89
+ git push -u origin "$BRANCH" 2>/dev/null
90
+ gh pr create \
91
+ --title "$TITLE" \
92
+ --body "## Scene: $SCENE
93
+
94
+ Verified in the Dev Lab (checklist + snapshot diffs attached).
95
+ Framework, duration, easing, reduced-motion policy and theme tokens per the signed motion spec.
96
+
97
+ ### Snapshots
98
+ | 0% | 50% | 100% |
99
+ |---|---|---|
100
+ | \`snapshot/frame-00.png\` | \`snapshot/frame-50.png\` | \`snapshot/frame-100.png\` |
101
+
102
+ Ready to review — comment fixes in the Dev Lab or approve to merge." \
103
+ && echo "== PR opened for scene: $SCENE =="
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env python3
2
+ """Acceptance gate for a scene in src/output.
3
+
4
+ This gate is intentionally stricter than the exploratory Dev Lab: it requires
5
+ one project context, a JSON motion spec bound to that context, a manifest,
6
+ runtime snapshots (not placeholders), and a completed checklist.
7
+ """
8
+
9
+ import argparse
10
+ import hashlib
11
+ import importlib.util
12
+ import json
13
+ import re
14
+ import sys
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ ROOT = Path(__file__).resolve().parents[1]
19
+ SAFE_SCENE = re.compile(r"^[A-Za-z0-9._-]+$")
20
+ sys.path.insert(0, str(ROOT / "scripts"))
21
+ from intelligence import validate_task_benchmark, validate_task_intelligence, validate_task_p1 # noqa: E402
22
+ sys.path.insert(0, str(ROOT / "src"))
23
+ from core.spec import validate_spec # noqa: E402
24
+
25
+
26
+ def _load_validator():
27
+ path = ROOT / "scripts" / "validate-lottie.py"
28
+ loader = importlib.util.spec_from_file_location("validate_lottie", path)
29
+ module = importlib.util.module_from_spec(loader)
30
+ loader.loader.exec_module(module)
31
+ return module
32
+
33
+
34
+ def _load_evidence_verifier():
35
+ path = ROOT / "scripts" / "evidence-verifier.py"
36
+ loader = importlib.util.spec_from_file_location("evidence_verifier", path)
37
+ module = importlib.util.module_from_spec(loader)
38
+ loader.loader.exec_module(module)
39
+ return module
40
+
41
+
42
+ def _load_attestation_verifier():
43
+ path = ROOT / "scripts" / "attestation-verifier.py"
44
+ loader = importlib.util.spec_from_file_location("attestation_verifier", path)
45
+ module = importlib.util.module_from_spec(loader)
46
+ loader.loader.exec_module(module)
47
+ return module
48
+
49
+
50
+ def _json(path: Path):
51
+ try:
52
+ return json.loads(path.read_text(encoding="utf-8"))
53
+ except (FileNotFoundError, json.JSONDecodeError) as exc:
54
+ raise ValueError(f"{path}: {exc}")
55
+
56
+
57
+ def _sha256_file(path: Path) -> str:
58
+ return hashlib.sha256(path.read_bytes()).hexdigest()
59
+
60
+
61
+ def _telemetry_bundle_sha256(task_dir: Path) -> str:
62
+ entries = []
63
+ for path in sorted(task_dir.glob("runtime-adapters/**/runtime-telemetry.json")):
64
+ if path.is_file() and task_dir.resolve() in path.resolve().parents:
65
+ entries.append({"path": path.resolve().relative_to(task_dir.resolve()).as_posix(), "sha256": _sha256_file(path)})
66
+ return hashlib.sha256(json.dumps(entries, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
67
+
68
+
69
+ 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) -> list[str]:
70
+ issues = []
71
+ manifest_path = scene_dir / "manifest.json"
72
+ spec_path = scene_dir / "motion-spec.json"
73
+ if not manifest_path.exists():
74
+ issues.append("missing manifest.json")
75
+ return issues
76
+ if not spec_path.exists():
77
+ issues.append("missing motion-spec.json; markdown specs cannot prove context binding")
78
+ return issues
79
+ if not context_path.exists():
80
+ issues.append(f"missing project context: {context_path}")
81
+ return issues
82
+ try:
83
+ manifest = _json(manifest_path)
84
+ spec = _json(spec_path)
85
+ except ValueError as exc:
86
+ return [str(exc)]
87
+
88
+ issues.extend(validate_spec(str(spec_path), str(context_path)))
89
+ context = _json(context_path)
90
+ if spec.get("context_binding", {}).get("name") != context.get("name"):
91
+ issues.append("spec context name does not match project-context.json")
92
+ if spec.get("theme", {}).get("primary") != (context.get("brand") or {}).get("primary"):
93
+ issues.append("spec primary color does not match project-context.json")
94
+ if manifest.get("framework") and manifest["framework"] != spec.get("framework"):
95
+ issues.append("manifest.framework does not match motion-spec.framework")
96
+ if manifest.get("category") and manifest["category"] != spec.get("category"):
97
+ issues.append("manifest.category does not match motion-spec.category")
98
+ if not spec.get("source_binding"):
99
+ issues.append("motion spec has no source_binding")
100
+ if not (spec.get("accessibility") or {}).get("reduced_motion"):
101
+ issues.append("motion spec has no reduced-motion policy")
102
+
103
+ checks = manifest.get("checks")
104
+ if not isinstance(checks, list) or not checks:
105
+ issues.append("manifest.checks must contain the Dev Lab quality checklist")
106
+ else:
107
+ failed = [c.get("id", "unnamed") for c in checks if c.get("pass") is not True]
108
+ if failed:
109
+ issues.append(f"Dev Lab checklist has failing/unconfirmed checks: {', '.join(failed)}")
110
+
111
+ snapshot_dir = scene_dir / "snapshot"
112
+ required = [snapshot_dir / f"frame-{p:02d}.png" for p in (0, 50, 100)]
113
+ for frame in required:
114
+ if not frame.is_file() or frame.stat().st_size == 0:
115
+ issues.append(f"missing snapshot frame: {frame.name}")
116
+ meta_path = snapshot_dir / ".render-meta.json"
117
+ if not meta_path.exists():
118
+ issues.append("missing snapshot/.render-meta.json")
119
+ else:
120
+ try:
121
+ meta = _json(meta_path)
122
+ if meta.get("mode") != "runtime":
123
+ issues.append("snapshot evidence is not runtime-rendered (placeholder is rejected)")
124
+ if meta.get("scene") != scene_dir.name:
125
+ issues.append("snapshot metadata scene mismatch")
126
+ except ValueError as exc:
127
+ issues.append(str(exc))
128
+
129
+ source_name = manifest.get("file")
130
+ source_path_for_evidence = (scene_dir / source_name).resolve() if source_name else scene_dir / "__missing-source__"
131
+ source_sha_for_evidence = ""
132
+ if source_path_for_evidence.is_file() and scene_dir.resolve() in source_path_for_evidence.parents:
133
+ source_sha_for_evidence = hashlib.sha256(source_path_for_evidence.read_bytes()).hexdigest()
134
+ manifest_sha_for_evidence = _sha256_file(manifest_path)
135
+
136
+ if spec.get("framework") in {"rive", "gsap", "framer-motion"}:
137
+ evidence_name = manifest.get("runtime_evidence")
138
+ if not evidence_name:
139
+ issues.append(f"manifest.runtime_evidence is required for {spec.get('framework')} scenes")
140
+ else:
141
+ evidence_path = (scene_dir / evidence_name).resolve()
142
+ if not evidence_path.is_file() or scene_dir.resolve() not in evidence_path.parents:
143
+ issues.append("manifest.runtime_evidence must point to an existing file inside the scene directory")
144
+ else:
145
+ try:
146
+ evidence = _json(evidence_path)
147
+ if evidence.get("mode") != "runtime":
148
+ issues.append("runtime evidence mode must be runtime")
149
+ if not evidence.get("run_id"):
150
+ issues.append("runtime evidence run_id is required")
151
+ if evidence.get("status") != "pass":
152
+ issues.append("runtime evidence top-level status must be pass")
153
+ if evidence.get("framework") and evidence.get("framework") != spec.get("framework"):
154
+ issues.append("runtime evidence framework does not match motion-spec.framework")
155
+ if evidence.get("scene") != scene_dir.name:
156
+ issues.append("runtime evidence scene does not match scene directory")
157
+ if evidence.get("source_sha256") != source_sha_for_evidence:
158
+ issues.append("runtime evidence source_sha256 does not match manifest.file")
159
+ if evidence.get("manifest_sha256") != manifest_sha_for_evidence:
160
+ issues.append("runtime evidence manifest_sha256 does not match manifest.json")
161
+ if evidence.get("frameworks"):
162
+ match = next((item for item in evidence["frameworks"] if item.get("framework") == spec.get("framework")), None)
163
+ if not match or match.get("status") != "pass" or match.get("ready") is not True:
164
+ issues.append("runtime evidence does not contain a passing adapter result")
165
+ except ValueError as exc:
166
+ issues.append(str(exc))
167
+
168
+ source_binding = manifest.get("source_binding")
169
+ if not isinstance(source_binding, dict):
170
+ issues.append("manifest.source_binding is required for production scenes")
171
+ else:
172
+ for field in ("kind", "source_path", "authority", "license", "sha256"):
173
+ if not str(source_binding.get(field, "")).strip():
174
+ issues.append(f"manifest.source_binding.{field} is required")
175
+ if source_binding.get("kind") not in {"project", "library", "generated", "fixture", "remote"}:
176
+ issues.append("manifest.source_binding.kind is invalid")
177
+
178
+ source = manifest.get("file")
179
+ if not source:
180
+ issues.append("manifest.file is required")
181
+ else:
182
+ source_path = (scene_dir / source).resolve()
183
+ if not source_path.is_file() or scene_dir.resolve() not in source_path.parents:
184
+ issues.append("manifest.file must point to an existing file inside the scene directory")
185
+ else:
186
+ if isinstance(source_binding, dict):
187
+ if source_binding.get("source_path") != source:
188
+ issues.append("manifest.source_binding.source_path must match manifest.file")
189
+ source_sha = hashlib.sha256(source_path.read_bytes()).hexdigest()
190
+ if source_binding.get("sha256") != source_sha:
191
+ issues.append("manifest.source_binding.sha256 does not match manifest.file")
192
+ if spec.get("framework") in ("lottie", "dotlottie") and source_path.suffix in (".json", ".lottie"):
193
+ validator = _load_validator()
194
+ issues.extend(validator.validate(source_path, spec, int(spec.get("performance", {}).get("max_layers", 80))))
195
+
196
+ candidate_path = (task_dir / "browser-review.json") if task_dir else (scene_dir / "browser-review.json")
197
+ if not candidate_path.is_file():
198
+ issues.append("missing browser-review.json; runtime render must hand off to the internal browser Agent")
199
+ else:
200
+ try:
201
+ candidate = _json(candidate_path)
202
+ source_sha = hashlib.sha256(source_path.read_bytes()).hexdigest()
203
+ context_sha = hashlib.sha256(context_path.read_bytes()).hexdigest()
204
+ if candidate.get("scene") != scene_dir.name:
205
+ issues.append("browser review candidate scene mismatch")
206
+ if task_dir:
207
+ task = _json(task_dir / "task.json")
208
+ if candidate.get("task_id") != task.get("task_id"):
209
+ issues.append("browser review candidate task_id mismatch")
210
+ if candidate.get("source_sha256") != source_sha:
211
+ issues.append("browser review candidate source_sha256 is stale")
212
+ if candidate.get("context_sha256") not in {context_sha, spec.get("context_binding", {}).get("context_sha256")}:
213
+ issues.append("browser review candidate context_sha256 is stale")
214
+ if candidate.get("status") not in {"prepared", "opened", "reviewed", "approved"}:
215
+ issues.append(f"browser review candidate status is not reviewable: {candidate.get('status')}")
216
+ expires_at = candidate.get("expires_at")
217
+ if not expires_at:
218
+ issues.append("browser review candidate expiry is required")
219
+ else:
220
+ try:
221
+ if datetime.now(timezone.utc) > datetime.fromisoformat(expires_at.replace("Z", "+00:00")):
222
+ issues.append("browser review candidate has expired")
223
+ except (TypeError, ValueError):
224
+ issues.append("browser review candidate expiry is invalid")
225
+ if require_review:
226
+ review_path = (task_dir / "review.json") if task_dir else (scene_dir / "review.json")
227
+ if not review_path.is_file():
228
+ issues.append("PR gate requires review.json captured by the browser Agent")
229
+ else:
230
+ review = _json(review_path)
231
+ if review.get("decision") != "approved":
232
+ issues.append("PR gate requires browser review decision=approved")
233
+ if review.get("candidate_id") != candidate.get("candidate_id"):
234
+ issues.append("browser review approves a different candidate")
235
+ if task_dir:
236
+ task = _json(task_dir / "task.json")
237
+ if review.get("task_id") != task.get("task_id"):
238
+ issues.append("browser review task_id mismatch")
239
+ if not str(review.get("reviewer") or "").strip():
240
+ issues.append("browser review reviewer is required")
241
+ reviewed_at = review.get("reviewed_at")
242
+ if reviewed_at:
243
+ try:
244
+ if datetime.fromisoformat(reviewed_at.replace("Z", "+00:00")) > datetime.fromisoformat(expires_at.replace("Z", "+00:00")):
245
+ issues.append("browser review was recorded after candidate expiry")
246
+ except (TypeError, ValueError):
247
+ issues.append("browser review reviewed_at is invalid")
248
+ if candidate.get("status") != "approved":
249
+ issues.append("PR gate requires browser-review.json status=approved")
250
+ except (ValueError, OSError) as exc:
251
+ issues.append(str(exc))
252
+ if require_intelligence:
253
+ if not task_dir:
254
+ issues.append("Intelligence Core gate requires --task-dir")
255
+ else:
256
+ issues.extend(validate_task_intelligence(task_dir, scene_dir.name))
257
+ if require_p1:
258
+ if not task_dir:
259
+ issues.append("P1 gate requires --task-dir")
260
+ else:
261
+ p1_issues = validate_task_p1(task_dir, scene_dir.name)
262
+ if not all((task_dir / name).is_file() for name in ("semantic-lint-report.json", "continuity-report.json", "fix-plan.json")):
263
+ p1_issues.append("P1 gate requires semantic-lint-report.json, continuity-report.json and fix-plan.json")
264
+ issues.extend(p1_issues)
265
+ if require_benchmark:
266
+ if not task_dir:
267
+ issues.append("benchmark gate requires --task-dir")
268
+ else:
269
+ issues.extend(validate_task_benchmark(task_dir, scene_dir.name))
270
+ if require_telemetry:
271
+ if not task_dir:
272
+ issues.append("telemetry gate requires --task-dir")
273
+ else:
274
+ verifier = _load_evidence_verifier()
275
+ verification = verifier.verify(scene_dir, task_dir, "runtime-adapters/runtime-evidence.json", None)
276
+ if not verification.get("verified"):
277
+ issues.extend(f"external evidence verifier: {issue}" for issue in verification.get("issues", []))
278
+ if verification.get("approval") is not False:
279
+ issues.append("external evidence verifier must never grant approval")
280
+ if require_attestation:
281
+ if not task_dir:
282
+ issues.append("attestation gate requires --task-dir")
283
+ else:
284
+ task_path = task_dir / "task.json"
285
+ if not task_path.is_file():
286
+ issues.append("attestation gate requires task.json")
287
+ else:
288
+ try:
289
+ task = _json(task_path)
290
+ resolved_attestation = attestation_path or (task_dir / "attestation.json")
291
+ resolved_policy = trust_policy_path or (task_dir / "trust-policy.json")
292
+ verifier = _load_attestation_verifier()
293
+ verification, exit_code = verifier.verify_attestation(
294
+ str(resolved_attestation),
295
+ str(resolved_policy),
296
+ str(task.get("task_id") or ""),
297
+ scene_dir.name,
298
+ )
299
+ if exit_code != 0 or not verification.get("verified"):
300
+ issues.extend(f"signed attestation verifier: {issue}" for issue in verification.get("issues", []))
301
+ if not verification.get("issues"):
302
+ issues.append(f"signed attestation verifier failed with exit code {exit_code}")
303
+ if verification.get("approval") is not False:
304
+ issues.append("signed attestation must never grant approval")
305
+ statement = _json(resolved_attestation).get("statement", {})
306
+ predicate = statement.get("predicate", {}) if isinstance(statement, dict) else {}
307
+ expected_motion_ir = task_dir / "motion-ir.json"
308
+ expected_verifier_report = task_dir / "evidence-verifier-report.json"
309
+ expected_telemetry_hash = _telemetry_bundle_sha256(task_dir)
310
+ expected_bindings = {
311
+ "task_id": task.get("task_id"),
312
+ "scene": scene_dir.name,
313
+ "context_hash": _sha256_file(context_path),
314
+ "source_sha256": source_sha_for_evidence,
315
+ "manifest_sha256": manifest_sha_for_evidence,
316
+ "motion_ir_sha256": _sha256_file(expected_motion_ir) if expected_motion_ir.is_file() else "",
317
+ "evidence.runtime_evidence_sha256": _sha256_file(task_dir / "runtime-adapters" / "runtime-evidence.json"),
318
+ "evidence.runtime_telemetry_sha256": expected_telemetry_hash,
319
+ "evidence.verifier_report_sha256": _sha256_file(expected_verifier_report) if expected_verifier_report.is_file() else "",
320
+ }
321
+ for field, expected in expected_bindings.items():
322
+ actual = predicate.get(field) if "." not in field else (predicate.get("evidence") or {}).get(field.split(".", 1)[1])
323
+ if expected and actual != expected:
324
+ issues.append(f"signed attestation {field} binding mismatch")
325
+ elif not expected:
326
+ issues.append(f"signed attestation required binding source is missing: {field}")
327
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
328
+ issues.append(f"signed attestation contract: {exc}")
329
+ return issues
330
+
331
+
332
+ def main() -> int:
333
+ parser = argparse.ArgumentParser()
334
+ group = parser.add_mutually_exclusive_group(required=True)
335
+ group.add_argument("--scene")
336
+ group.add_argument("--all", action="store_true")
337
+ parser.add_argument("--root", default=str(ROOT))
338
+ parser.add_argument("--context", default="project-context.json")
339
+ parser.add_argument("--task-dir")
340
+ parser.add_argument("--require-browser-review", action="store_true")
341
+ parser.add_argument("--require-intelligence", action="store_true")
342
+ parser.add_argument("--require-p1", action="store_true")
343
+ parser.add_argument("--require-benchmark", action="store_true")
344
+ parser.add_argument("--require-telemetry", action="store_true")
345
+ parser.add_argument("--require-attestation", action="store_true")
346
+ parser.add_argument("--attestation")
347
+ parser.add_argument("--trust-policy")
348
+ args = parser.parse_args()
349
+ if args.scene and (args.scene in {".", ".."} or not SAFE_SCENE.fullmatch(args.scene)):
350
+ print("QUALITY GATE: unsafe scene identifier")
351
+ return 1
352
+ root = Path(args.root).resolve()
353
+ context = Path(args.context)
354
+ if not context.is_absolute():
355
+ context = root / context
356
+ output_root = root / "src" / "output"
357
+ task_dir = Path(args.task_dir).resolve() if args.task_dir else None
358
+ attestation_path = Path(args.attestation).resolve() if args.attestation else None
359
+ trust_policy_path = Path(args.trust_policy).resolve() if args.trust_policy else None
360
+ 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 []
361
+ if not scenes:
362
+ print("QUALITY GATE: no scene outputs found")
363
+ return 0
364
+ failed = False
365
+ for scene_dir in scenes:
366
+ 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)
367
+ if issues:
368
+ failed = True
369
+ print(f"REJECTED {scene_dir.name}:")
370
+ for issue in issues:
371
+ print(f" - {issue}")
372
+ else:
373
+ print(f"ACCEPTED {scene_dir.name}: context + spec + runtime snapshots + browser-review candidate + checklist")
374
+ return 1 if failed else 0
375
+
376
+
377
+ if __name__ == "__main__":
378
+ raise SystemExit(main())
@@ -0,0 +1,53 @@
1
+ /**
2
+ * render-node.mjs — Deterministic single-frame Lottie rendering in Node,
3
+ * using the official dotlottie runtime + @napi-rs/canvas. Reads SRC (file),
4
+ * FRAME_PCT (0-100) and writes a PNG to OUT. Bit-exact across machines,
5
+ * which makes it the source of truth for visual regression tests.
6
+ *
7
+ * Env: SRC=<animation.lottie|.json> FRAME_PCT=50 OUT=frame-50.png
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { DotLottie } from "@lottiefiles/dotlottie-web";
12
+ import { createCanvas } from "@napi-rs/canvas";
13
+
14
+ const src = process.env.SRC;
15
+ const pct = Number(process.env.FRAME_PCT || 0);
16
+ const out = process.env.OUT || "frame.png";
17
+ const SIZE = Number(process.env.RENDER_SIZE || 512);
18
+
19
+ if (!src) {
20
+ console.error("usage: SRC=<file> FRAME_PCT=<0-100> OUT=<png> node render-node.mjs");
21
+ process.exit(1);
22
+ }
23
+
24
+ const canvas = createCanvas(SIZE, SIZE);
25
+ const sourcePath = path.resolve(src);
26
+ if (!fs.existsSync(sourcePath)) {
27
+ console.error(`source file not found: ${sourcePath}`);
28
+ process.exit(1);
29
+ }
30
+ const bytes = fs.readFileSync(sourcePath);
31
+ const data = sourcePath.endsWith(".json")
32
+ ? JSON.parse(bytes.toString("utf8"))
33
+ : new Uint8Array(bytes);
34
+
35
+ const dotLottie = new DotLottie({
36
+ canvas,
37
+ data,
38
+ autoplay: false,
39
+ renderConfig: { devicePixelRatio: 1 },
40
+ });
41
+
42
+ dotLottie.addEventListener("load", async () => {
43
+ const target = Math.round((pct / 100) * (dotLottie.totalFrames - 1));
44
+ dotLottie.setFrame(target);
45
+ fs.writeFileSync(out, await canvas.encode("png"));
46
+ dotLottie.destroy();
47
+ process.exit(0);
48
+ });
49
+
50
+ dotLottie.addEventListener("loadError", (e) => {
51
+ console.error("loadError:", e);
52
+ process.exit(2);
53
+ });
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+ # render.sh — Step 5 of the pipeline: render deterministic snapshot frames
3
+ # (0%, 50%, 100%) of a scene for visual verification and PR attachments.
4
+ #
5
+ # Usage: bash scripts/render.sh <scene>
6
+ set -euo pipefail
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ REPO="$(dirname "$SCRIPT_DIR")"
9
+ SCENE="${1:?usage: bash scripts/render.sh <scene>}"
10
+ SCENE_DIR="$REPO/src/output/$SCENE"
11
+
12
+ if [[ ! "$SCENE" =~ ^[A-Za-z0-9._-]+$ ]]; then
13
+ echo "error: scene id contains unsafe path characters: $SCENE" >&2
14
+ exit 1
15
+ fi
16
+
17
+ if [ ! -d "$SCENE_DIR" ]; then
18
+ echo "error: scene directory not found: $SCENE_DIR"
19
+ exit 1
20
+ fi
21
+
22
+ mkdir -p "$SCENE_DIR/snapshot"
23
+
24
+ EXTRA_ARGS=()
25
+ if [ "${ALLOW_PLACEHOLDER:-0}" = "1" ]; then
26
+ EXTRA_ARGS+=(--allow-placeholder)
27
+ fi
28
+
29
+ python3 "$REPO/src/core/snapshot.py" render "$SCENE" \
30
+ --scene-dir "$SCENE_DIR" \
31
+ --progress 0,50,100 "${EXTRA_ARGS[@]}"
32
+
33
+ if [ ! -f "$SCENE_DIR/snapshot/.render-meta.json" ]; then
34
+ echo "error: renderer did not write snapshot metadata" >&2
35
+ exit 1
36
+ fi
37
+ echo "== runtime snapshots written to $SCENE_DIR/snapshot =="