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.
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/SKILL.md +122 -0
- package/agent-card.json +161 -0
- package/assets/library/ATTRIBUTION.md +6 -0
- package/assets/library/README.md +20 -0
- package/assets/library/avatar-base.svg +19 -0
- package/assets/library/error-alert.json +1 -0
- package/assets/library/rive/ATTRIBUTION.md +12 -0
- package/assets/library/rive/state-machine-test.riv +0 -0
- package/assets/library/success-check.json +1 -0
- package/bin/motionloom.mjs +81 -0
- package/docs/BROWSER-REVIEW-E2E.md +78 -0
- package/docs/CATEGORIES.md +16 -0
- package/docs/CHECKLIST.md +31 -0
- package/docs/DEEP-AUDIT-WORKING-NOTES.md +22 -0
- package/docs/FRAMEWORK-SELECTION.md +26 -0
- package/docs/PROJECT-MANIFEST.md +37 -0
- package/docs/ROADMAP-INTELLIGENCE.md +224 -0
- package/docs/audits/1.10.0-attestation-research-notes.md +19 -0
- package/docs/audits/1.8.0-trust-boundary-hardening.md +56 -0
- package/docs/audits/1.9.0-evidence-interoperability-threat-model.md +37 -0
- package/docs/audits/2.0.0-attestation-acceptance.md +30 -0
- package/docs/releases/1.5.0.md +25 -0
- package/docs/releases/1.6.0.md +27 -0
- package/docs/releases/1.7.0.md +23 -0
- package/docs/releases/1.8.0.md +23 -0
- package/docs/releases/1.9.0.md +21 -0
- package/docs/releases/2.0.0.md +21 -0
- package/docs/releases/npm-publish-from-workstation.md +88 -0
- package/docs/research/AGENT-PROTOCOL-FINDINGS.md +43 -0
- package/examples/report-demo/REPORT.md +50 -0
- package/examples/report-demo/artifact-manifest.json +25 -0
- package/examples/report-demo/decision-log.jsonl +0 -0
- package/examples/report-demo/execution-report.json +70 -0
- package/examples/report-demo/handoff.json +22 -0
- package/examples/report-demo/issue-register.json +5 -0
- package/examples/report-demo/task.json +13 -0
- package/package.json +95 -0
- package/project-context.example.json +26 -0
- package/references/browser-review-contract.md +32 -0
- package/references/dotlottie-source-notes.md +21 -0
- package/references/intelligence-core.md +98 -0
- package/references/reporting-contract.md +38 -0
- package/references/runtime-capability.md +12 -0
- package/references/signed-attestation.md +31 -0
- package/schemas/artifact-manifest.schema.json +22 -0
- package/schemas/browser-review-candidate.schema.json +24 -0
- package/schemas/capability-registry.schema.json +61 -0
- package/schemas/continuity-report.schema.json +52 -0
- package/schemas/evidence-verifier-report.schema.json +35 -0
- package/schemas/execution-report.schema.json +35 -0
- package/schemas/fix-plan.schema.json +44 -0
- package/schemas/handoff.schema.json +19 -0
- package/schemas/motion-ir.schema.json +74 -0
- package/schemas/project-graph.schema.json +70 -0
- package/schemas/provenance.schema.json +76 -0
- package/schemas/runtime-evidence.schema.json +48 -0
- package/schemas/runtime-telemetry.schema.json +50 -0
- package/schemas/scene-manifest.schema.json +45 -0
- package/schemas/semantic-benchmark.schema.json +26 -0
- package/schemas/semantic-lint-report.schema.json +48 -0
- package/schemas/signed-attestation.schema.json +95 -0
- package/schemas/task.schema.json +39 -0
- package/schemas/trust-policy.schema.json +53 -0
- package/scripts/analyze.sh +13 -0
- package/scripts/attestation-keygen.py +63 -0
- package/scripts/attestation-verifier.py +178 -0
- package/scripts/attestation.py +288 -0
- package/scripts/capture-runtime-telemetry.sh +37 -0
- package/scripts/devlab.sh +77 -0
- package/scripts/eval-intelligence.py +377 -0
- package/scripts/evidence-verifier.py +222 -0
- package/scripts/fetch-library.sh +57 -0
- package/scripts/intelligence.py +1543 -0
- package/scripts/manifest.py +61 -0
- package/scripts/pr.sh +103 -0
- package/scripts/quality-gate.py +378 -0
- package/scripts/render-node.mjs +53 -0
- package/scripts/render.sh +37 -0
- package/scripts/report-contract.py +181 -0
- package/scripts/report.py +588 -0
- package/scripts/review-hook.py +199 -0
- package/scripts/runtime-adapters.mjs +187 -0
- package/scripts/skill-doctor.py +150 -0
- package/scripts/to-dotlottie.mjs +99 -0
- package/scripts/to-dotlottie.sh +25 -0
- package/scripts/validate-lottie.py +102 -0
- package/src/core/analyzer.py +226 -0
- package/src/core/snapshot.py +124 -0
- package/src/core/spec.py +240 -0
- package/src/output/browser-review-smoke/animation.json +57 -0
- package/src/output/browser-review-smoke/browser-review.json +21 -0
- package/src/output/browser-review-smoke/manifest.json +22 -0
- package/src/output/browser-review-smoke/motion-spec.json +28 -0
- package/src/output/browser-review-smoke/snapshot/.render-meta.json +10 -0
- package/src/output/browser-review-smoke/snapshot/frame-00.png +0 -0
- package/src/output/browser-review-smoke/snapshot/frame-100.png +0 -0
- package/src/output/browser-review-smoke/snapshot/frame-50.png +0 -0
- package/src/rig/README.md +35 -0
- package/src/rig/cutout_rig.py +211 -0
- package/templates/framer-motion/ui-micro.tsx +50 -0
- package/templates/gsap/scroll-scene.js +54 -0
- package/templates/lottie/README.md +21 -0
- package/templates/lottie/react-component.tsx +82 -0
- package/templates/lottie/scaffold/animation.json +57 -0
- package/templates/lottie/scaffold/character-rig.svg +19 -0
- package/templates/lottie/vanilla.js +68 -0
- package/templates/rive/README.md +36 -0
- package/tests/evals/intelligence-cases.json +131 -0
- package/tests/scripts/run_tests.py +843 -0
- package/tests/scripts/test_attestation.py +172 -0
|
@@ -0,0 +1,1543 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MotionLoom Intelligence Core v0.1.
|
|
3
|
+
|
|
4
|
+
The CLI keeps the first intelligence layer deterministic and artifact-first. It
|
|
5
|
+
does not make aesthetic claims; it creates and verifies the relationships and
|
|
6
|
+
evidence an Agent needs before making those claims.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import platform
|
|
16
|
+
import time
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
25
|
+
SHA256_RE = "^[a-f0-9]{64}$"
|
|
26
|
+
GENERATED_REPLAY = {"replay-bundle.json"}
|
|
27
|
+
GENERATED_REPORTS = {"artifact-manifest.json", "execution-report.json", "REPORT.md", "decision-log.jsonl", "semantic-lint-benchmark.json"}
|
|
28
|
+
UI_CONTEXT_CATEGORIES = {
|
|
29
|
+
"button", "dialog", "drawer", "dropdown", "form", "hero", "interaction", "loading", "menu",
|
|
30
|
+
"modal", "navigation", "notification", "popover", "scroll", "tooltip", "ui", "ui-micro",
|
|
31
|
+
}
|
|
32
|
+
SEMANTIC_RULE_IDS = (
|
|
33
|
+
"MOTION.TIMING.DURATION", "MOTION.TIMING.FPS", "MOTION.INTENT.PRESENT", "MOTION.INTENT.SPECIFIC",
|
|
34
|
+
"MOTION.EASING.KNOWN", "MOTION.PERCEPTUAL.EASING", "MOTION.A11Y.REDUCED_MOTION",
|
|
35
|
+
"MOTION.PERCEPTUAL.REDUCED_MOTION", "MOTION.A11Y.KEYBOARD_REVIEW", "MOTION.PERF.MAX_TRACKS",
|
|
36
|
+
"MOTION.PERF.ANIMATION_BUDGET", "MOTION.PERF.FRAME_RATE", "MOTION.PERF.TRACK_COUNT",
|
|
37
|
+
"MOTION.ACCEPTANCE.PRESENT",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def now() -> str:
|
|
42
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def canonical(value: Any) -> bytes:
|
|
46
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def digest_bytes(value: bytes) -> str:
|
|
50
|
+
return hashlib.sha256(value).hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def digest_file(path: Path) -> str:
|
|
54
|
+
return digest_bytes(path.read_bytes())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def read_json(path: Path) -> Any:
|
|
58
|
+
if not path.is_file():
|
|
59
|
+
raise ValueError(f"missing JSON file: {path}")
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
62
|
+
except json.JSONDecodeError as exc:
|
|
63
|
+
raise ValueError(f"invalid JSON in {path}: {exc}") from exc
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_json(path: Path, value: Any) -> None:
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def task_dir_from(value: str) -> Path:
|
|
72
|
+
path = Path(value).expanduser().resolve()
|
|
73
|
+
if not path.is_dir():
|
|
74
|
+
raise ValueError(f"task directory does not exist: {path}")
|
|
75
|
+
return path
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def safe_relative(value: str) -> Path:
|
|
79
|
+
path = Path(value)
|
|
80
|
+
if path.is_absolute() or not value or any(part == ".." for part in path.parts):
|
|
81
|
+
raise ValueError(f"unsafe artifact path: {value}")
|
|
82
|
+
return path
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def has_symlink_component(path: Path) -> bool:
|
|
86
|
+
current = path
|
|
87
|
+
while True:
|
|
88
|
+
if current.is_symlink():
|
|
89
|
+
return True
|
|
90
|
+
if current.parent == current:
|
|
91
|
+
return False
|
|
92
|
+
current = current.parent
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def within(root: Path, candidate: Path) -> bool:
|
|
96
|
+
try:
|
|
97
|
+
candidate.resolve().relative_to(root.resolve())
|
|
98
|
+
return True
|
|
99
|
+
except ValueError:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def task_artifact(task_dir: Path, relative: str) -> dict[str, Any]:
|
|
104
|
+
safe = safe_relative(relative)
|
|
105
|
+
raw_path = task_dir / safe
|
|
106
|
+
path = raw_path.resolve()
|
|
107
|
+
if has_symlink_component(raw_path) or not within(task_dir, path) or not path.is_file():
|
|
108
|
+
raise ValueError(f"artifact is missing or outside task bundle: {relative}")
|
|
109
|
+
return {
|
|
110
|
+
"id": relative.replace("/", ":"),
|
|
111
|
+
"path": path.relative_to(task_dir).as_posix(),
|
|
112
|
+
"type": path.suffix.lstrip(".") or "file",
|
|
113
|
+
"sha256": digest_file(path),
|
|
114
|
+
"bytes": path.stat().st_size,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_context(task_dir: Path, raw: str | None) -> Path | None:
|
|
119
|
+
if not raw:
|
|
120
|
+
return None
|
|
121
|
+
candidate = Path(raw).expanduser()
|
|
122
|
+
options = [candidate] if candidate.is_absolute() else [task_dir / candidate, ROOT / candidate]
|
|
123
|
+
for option in options:
|
|
124
|
+
resolved = option.resolve()
|
|
125
|
+
if resolved.is_file():
|
|
126
|
+
return resolved
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def package_version() -> str:
|
|
131
|
+
package = ROOT / "package.json"
|
|
132
|
+
try:
|
|
133
|
+
return str(read_json(package).get("version", "unknown"))
|
|
134
|
+
except ValueError:
|
|
135
|
+
return "unknown"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def add_node(nodes: dict[str, dict[str, Any]], node_id: str, kind: str, label: str, **kwargs: Any) -> None:
|
|
139
|
+
node = {"id": node_id, "kind": kind, "label": label}
|
|
140
|
+
node.update({key: value for key, value in kwargs.items() if value is not None})
|
|
141
|
+
nodes.setdefault(node_id, node)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def graph_build(args: argparse.Namespace) -> int:
|
|
145
|
+
task_dir = task_dir_from(args.task_dir)
|
|
146
|
+
task = read_json(task_dir / "task.json")
|
|
147
|
+
if not isinstance(task, dict) or not task.get("task_id") or not task.get("scene"):
|
|
148
|
+
raise ValueError("task.json must contain task_id and scene")
|
|
149
|
+
|
|
150
|
+
task_id = str(task["task_id"])
|
|
151
|
+
scene = str(task["scene"])
|
|
152
|
+
context_path = resolve_context(task_dir, task.get("context_path"))
|
|
153
|
+
context_hash = str(task.get("context_hash") or "")
|
|
154
|
+
if context_path and len(context_hash) != 64:
|
|
155
|
+
context_hash = digest_file(context_path)
|
|
156
|
+
if len(context_hash) != 64:
|
|
157
|
+
context_hash = digest_bytes(canonical({"task_id": task_id, "scene": scene, "intent": task.get("intent", "")}))
|
|
158
|
+
|
|
159
|
+
nodes: dict[str, dict[str, Any]] = {}
|
|
160
|
+
edges: list[dict[str, Any]] = []
|
|
161
|
+
project_id = f"project:{task.get('project_name') or task_id}"
|
|
162
|
+
intent_id = f"intent:{task_id}"
|
|
163
|
+
scene_id = f"scene:{scene}"
|
|
164
|
+
add_node(nodes, project_id, "project", str(task.get("project_name") or task_id), ref=task_id)
|
|
165
|
+
add_node(nodes, intent_id, "intent", str(task.get("intent") or "Animation task intent"), ref=task_id)
|
|
166
|
+
add_node(nodes, scene_id, "scene", scene, ref=scene)
|
|
167
|
+
edges.extend([
|
|
168
|
+
{"from": project_id, "to": intent_id, "relation": "contains"},
|
|
169
|
+
{"from": project_id, "to": scene_id, "relation": "contains"},
|
|
170
|
+
{"from": scene_id, "to": intent_id, "relation": "constrained_by"},
|
|
171
|
+
])
|
|
172
|
+
|
|
173
|
+
if context_path:
|
|
174
|
+
context_id = f"artifact:{context_path.name}"
|
|
175
|
+
add_node(nodes, context_id, "artifact", context_path.name, ref=str(context_path), sha256=digest_file(context_path))
|
|
176
|
+
edges.append({"from": scene_id, "to": context_id, "relation": "derived_from"})
|
|
177
|
+
else:
|
|
178
|
+
context_id = None
|
|
179
|
+
|
|
180
|
+
task_files = []
|
|
181
|
+
for path in sorted(task_dir.rglob("*")):
|
|
182
|
+
if has_symlink_component(path):
|
|
183
|
+
raise ValueError(f"task bundle cannot contain symlinked artifact: {path.relative_to(task_dir)}")
|
|
184
|
+
if not path.is_file():
|
|
185
|
+
continue
|
|
186
|
+
relative = path.relative_to(task_dir).as_posix()
|
|
187
|
+
if relative in {"project-graph.json", "provenance.json", "replay-bundle.json"}:
|
|
188
|
+
continue
|
|
189
|
+
task_files.append((relative, path))
|
|
190
|
+
|
|
191
|
+
for relative, path in task_files:
|
|
192
|
+
lower = relative.lower()
|
|
193
|
+
if relative == "review.json" or "review" in lower and relative.endswith(".json"):
|
|
194
|
+
kind = "review"
|
|
195
|
+
elif relative == "motion-ir.json":
|
|
196
|
+
kind = "motion_spec"
|
|
197
|
+
elif relative == "task.json" or "report" in lower or "handoff" in lower:
|
|
198
|
+
kind = "evidence"
|
|
199
|
+
else:
|
|
200
|
+
kind = "artifact"
|
|
201
|
+
node_id = f"artifact:{relative}"
|
|
202
|
+
add_node(nodes, node_id, kind, relative, ref=relative, sha256=digest_file(path))
|
|
203
|
+
edges.append({"from": scene_id, "to": node_id, "relation": "uses"})
|
|
204
|
+
|
|
205
|
+
review_id = "review:" + task_id
|
|
206
|
+
if (task_dir / "review.json").is_file() or task.get("browser_review", {}).get("status"):
|
|
207
|
+
add_node(nodes, review_id, "review", "Browser review", ref="review.json")
|
|
208
|
+
edges.append({"from": scene_id, "to": review_id, "relation": "reviewed_as"})
|
|
209
|
+
|
|
210
|
+
graph = {
|
|
211
|
+
"schema_version": "0.1",
|
|
212
|
+
"graph_id": f"graph-{task_id}-{scene}",
|
|
213
|
+
"task_id": task_id,
|
|
214
|
+
"scene": scene,
|
|
215
|
+
"generated_at": now(),
|
|
216
|
+
"context_hash": context_hash,
|
|
217
|
+
"nodes": list(nodes.values()),
|
|
218
|
+
"edges": edges,
|
|
219
|
+
"roots": [project_id, scene_id],
|
|
220
|
+
"policy": {
|
|
221
|
+
"source_of_truth": "task-bundle",
|
|
222
|
+
"allow_unresolved_edges": False,
|
|
223
|
+
"required_node_kinds": ["project", "intent", "scene"],
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
output = Path(args.output).expanduser().resolve() if args.output else task_dir / "project-graph.json"
|
|
227
|
+
write_json(output, graph)
|
|
228
|
+
print(json.dumps({"status": "built", "kind": "project-graph", "task_id": task_id, "node_count": len(nodes), "edge_count": len(edges), "path": str(output)}, ensure_ascii=False))
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def graph_validate(args: argparse.Namespace) -> int:
|
|
233
|
+
graph = read_json(Path(args.path).expanduser().resolve())
|
|
234
|
+
required = {"schema_version", "graph_id", "task_id", "scene", "context_hash", "nodes", "edges", "roots"}
|
|
235
|
+
missing = sorted(required - set(graph)) if isinstance(graph, dict) else sorted(required)
|
|
236
|
+
if missing:
|
|
237
|
+
raise ValueError(f"project graph missing fields: {', '.join(missing)}")
|
|
238
|
+
node_ids = {node.get("id") for node in graph["nodes"] if isinstance(node, dict)}
|
|
239
|
+
if len(node_ids) != len(graph["nodes"]):
|
|
240
|
+
raise ValueError("project graph contains duplicate or invalid node ids")
|
|
241
|
+
if any(root not in node_ids for root in graph["roots"]):
|
|
242
|
+
raise ValueError("project graph root does not reference a node")
|
|
243
|
+
for edge in graph["edges"]:
|
|
244
|
+
if edge.get("from") not in node_ids or edge.get("to") not in node_ids:
|
|
245
|
+
raise ValueError("project graph edge references an unknown node")
|
|
246
|
+
if not isinstance(graph["context_hash"], str) or len(graph["context_hash"]) != 64:
|
|
247
|
+
raise ValueError("project graph context_hash must be a SHA-256 digest")
|
|
248
|
+
print(json.dumps({"status": "valid", "kind": "project-graph", "task_id": graph["task_id"], "node_count": len(node_ids), "edge_count": len(graph["edges"])}, ensure_ascii=False))
|
|
249
|
+
return 0
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def default_provenance_steps(task_dir: Path, task: dict[str, Any]) -> list[dict[str, Any]]:
|
|
253
|
+
files = {path.name: path for path in task_dir.iterdir() if path.is_file()}
|
|
254
|
+
steps: list[dict[str, Any]] = []
|
|
255
|
+
|
|
256
|
+
def existing(names: list[str]) -> list[str]:
|
|
257
|
+
return [name for name in names if name in files]
|
|
258
|
+
|
|
259
|
+
candidates = [
|
|
260
|
+
("context", "context", ["task.json"], ["task.json"], "agent"),
|
|
261
|
+
("spec", "spec", ["task.json"], ["motion-spec.json"], "agent"),
|
|
262
|
+
("motion-ir", "motion-ir", ["task.json"], ["motion-ir.json"], "agent"),
|
|
263
|
+
("source-bind", "source-bind", ["motion-spec.json"], ["manifest.json", "manifest.yaml"], "agent"),
|
|
264
|
+
("render", "render", ["manifest.json"], ["render-meta.json", ".render-meta.json", "animation.json"], "runtime"),
|
|
265
|
+
("runtime-test", "runtime-test", ["animation.json"], ["runtime-evidence.json"], "runtime"),
|
|
266
|
+
("browser-review", "browser-review", ["browser-review.json"], ["review.json", "browser-observation.md"], "user"),
|
|
267
|
+
("quality-gate", "quality-gate", ["browser-review.json", "review.json"], ["quality-report.json"], "ci"),
|
|
268
|
+
("confirm", "confirm", ["quality-report.json"], ["task.json"], "user"),
|
|
269
|
+
]
|
|
270
|
+
for step_id, step_type, material_names, product_names, actor_type in candidates:
|
|
271
|
+
materials = existing(material_names)
|
|
272
|
+
products = existing(product_names)
|
|
273
|
+
if not materials and not products:
|
|
274
|
+
continue
|
|
275
|
+
timestamp = now()
|
|
276
|
+
step = {
|
|
277
|
+
"step_id": step_id,
|
|
278
|
+
"step_type": step_type,
|
|
279
|
+
"actor": {"type": actor_type, "id": "motionloom" if actor_type != "user" else "user"},
|
|
280
|
+
"builder": {"name": "motionloom", "version": package_version(), "os": platform.system().lower()},
|
|
281
|
+
"materials": [],
|
|
282
|
+
"products": [],
|
|
283
|
+
"policy": f"schemas/provenance.schema.json#{step_type}",
|
|
284
|
+
"started_at": timestamp,
|
|
285
|
+
"finished_at": timestamp,
|
|
286
|
+
"result": "pass",
|
|
287
|
+
"parent_step_ids": [steps[-1]["step_id"]] if steps else [],
|
|
288
|
+
}
|
|
289
|
+
for name in materials:
|
|
290
|
+
step["materials"].append(task_artifact(task_dir, name))
|
|
291
|
+
for name in products:
|
|
292
|
+
step["products"].append(task_artifact(task_dir, name))
|
|
293
|
+
steps.append(step)
|
|
294
|
+
if not steps:
|
|
295
|
+
timestamp = now()
|
|
296
|
+
steps.append({
|
|
297
|
+
"step_id": "context",
|
|
298
|
+
"step_type": "context",
|
|
299
|
+
"actor": {"type": "agent", "id": "motionloom"},
|
|
300
|
+
"builder": {"name": "motionloom", "version": package_version(), "os": platform.system().lower()},
|
|
301
|
+
"materials": [],
|
|
302
|
+
"products": [task_artifact(task_dir, "task.json")],
|
|
303
|
+
"policy": "schemas/provenance.schema.json#context",
|
|
304
|
+
"started_at": timestamp,
|
|
305
|
+
"finished_at": timestamp,
|
|
306
|
+
"result": "pass",
|
|
307
|
+
"parent_step_ids": [],
|
|
308
|
+
})
|
|
309
|
+
return steps
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def normalize_step_artifacts(task_dir: Path, steps: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
313
|
+
normalized = []
|
|
314
|
+
for step in steps:
|
|
315
|
+
item = dict(step)
|
|
316
|
+
for field in ("materials", "products"):
|
|
317
|
+
entries = []
|
|
318
|
+
for artifact in item.get(field, []):
|
|
319
|
+
if isinstance(artifact, str):
|
|
320
|
+
entries.append(task_artifact(task_dir, artifact))
|
|
321
|
+
elif isinstance(artifact, dict):
|
|
322
|
+
entries.append(task_artifact(task_dir, str(artifact.get("path", ""))))
|
|
323
|
+
else:
|
|
324
|
+
raise ValueError(f"invalid {field} artifact in step {step.get('step_id')}")
|
|
325
|
+
item[field] = entries
|
|
326
|
+
normalized.append(item)
|
|
327
|
+
return normalized
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def provenance_build(args: argparse.Namespace) -> int:
|
|
331
|
+
task_dir = task_dir_from(args.task_dir)
|
|
332
|
+
task = read_json(task_dir / "task.json")
|
|
333
|
+
if not isinstance(task, dict):
|
|
334
|
+
raise ValueError("task.json must be an object")
|
|
335
|
+
if args.steps_file:
|
|
336
|
+
raw_steps = read_json(Path(args.steps_file).expanduser().resolve())
|
|
337
|
+
if not isinstance(raw_steps, list):
|
|
338
|
+
raise ValueError("steps file must contain an array")
|
|
339
|
+
steps = normalize_step_artifacts(task_dir, raw_steps)
|
|
340
|
+
else:
|
|
341
|
+
steps = default_provenance_steps(task_dir, task)
|
|
342
|
+
chain_hash = digest_bytes(canonical(steps))
|
|
343
|
+
attestation = {
|
|
344
|
+
"schema_version": "0.1",
|
|
345
|
+
"attestation_id": f"attestation-{task.get('task_id', task_dir.name)}",
|
|
346
|
+
"task_id": str(task.get("task_id", task_dir.name)),
|
|
347
|
+
"scene": str(task.get("scene", "unknown")),
|
|
348
|
+
"generated_at": now(),
|
|
349
|
+
"subjects": [task_artifact(task_dir, args.subject)] if args.subject else [task_artifact(task_dir, "task.json")],
|
|
350
|
+
"steps": steps,
|
|
351
|
+
"verification": {"hash_algorithm": "sha256", "chain_hash": chain_hash, "status": "verified", "signature": {"status": "unsigned"}},
|
|
352
|
+
}
|
|
353
|
+
output = Path(args.output).expanduser().resolve() if args.output else task_dir / "provenance.json"
|
|
354
|
+
write_json(output, attestation)
|
|
355
|
+
print(json.dumps({"status": "built", "kind": "provenance", "task_id": attestation["task_id"], "step_count": len(steps), "chain_hash": chain_hash, "path": str(output)}, ensure_ascii=False))
|
|
356
|
+
return 0
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def provenance_validate(args: argparse.Namespace) -> int:
|
|
360
|
+
raw_path = Path(args.path).expanduser()
|
|
361
|
+
path = raw_path.resolve()
|
|
362
|
+
attestation = read_json(path)
|
|
363
|
+
if not isinstance(attestation, dict) or not isinstance(attestation.get("steps"), list):
|
|
364
|
+
raise ValueError("provenance attestation must contain steps")
|
|
365
|
+
task_dir = task_dir_from(args.task_dir)
|
|
366
|
+
if has_symlink_component(raw_path) or not within(task_dir, path):
|
|
367
|
+
raise ValueError("provenance attestation must be a non-symlink file inside task bundle")
|
|
368
|
+
if attestation.get("task_id") != read_json(task_dir / "task.json").get("task_id"):
|
|
369
|
+
raise ValueError("provenance task_id does not match task.json")
|
|
370
|
+
expected_chain = digest_bytes(canonical(attestation["steps"]))
|
|
371
|
+
actual_chain = attestation.get("verification", {}).get("chain_hash")
|
|
372
|
+
if expected_chain != actual_chain:
|
|
373
|
+
raise ValueError("provenance chain_hash mismatch")
|
|
374
|
+
known_steps = {step.get("step_id") for step in attestation["steps"]}
|
|
375
|
+
for step in attestation["steps"]:
|
|
376
|
+
if any(parent not in known_steps for parent in step.get("parent_step_ids", [])):
|
|
377
|
+
raise ValueError(f"provenance parent step missing for {step.get('step_id')}")
|
|
378
|
+
for field in ("materials", "products"):
|
|
379
|
+
for artifact in step.get(field, []):
|
|
380
|
+
checked = task_artifact(task_dir, str(artifact.get("path", "")))
|
|
381
|
+
if checked["sha256"] != artifact.get("sha256"):
|
|
382
|
+
raise ValueError(f"provenance {field} hash mismatch: {artifact.get('path')}")
|
|
383
|
+
print(json.dumps({"status": "valid", "kind": "provenance", "task_id": attestation["task_id"], "step_count": len(attestation["steps"]), "chain_hash": actual_chain}, ensure_ascii=False))
|
|
384
|
+
return 0
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def capability_kind(capability: str) -> str:
|
|
388
|
+
if capability in {"dotlottie-package"}:
|
|
389
|
+
return "packager"
|
|
390
|
+
if capability in {"lottie-json", "svg-cutout-rig"}:
|
|
391
|
+
return "renderer"
|
|
392
|
+
if capability in {"rive", "gsap", "framer-motion"}:
|
|
393
|
+
return "runtime-adapter"
|
|
394
|
+
return "renderer"
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def capability_build(args: argparse.Namespace) -> int:
|
|
398
|
+
card_path = Path(args.card).expanduser().resolve()
|
|
399
|
+
card = read_json(card_path)
|
|
400
|
+
if not isinstance(card, dict):
|
|
401
|
+
raise ValueError("agent-card must be an object")
|
|
402
|
+
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
|
+
evidence_path = raw_evidence_path.resolve()
|
|
406
|
+
if not evidence_path.is_file():
|
|
407
|
+
raise ValueError(f"capability evidence path does not exist: {evidence_path}")
|
|
408
|
+
if not within(ROOT, evidence_path):
|
|
409
|
+
raise ValueError("capability evidence must be inside the repository")
|
|
410
|
+
evidence_ref = evidence_path.relative_to(ROOT).as_posix()
|
|
411
|
+
evidence_kind = args.evidence_kind
|
|
412
|
+
entries = []
|
|
413
|
+
verified = list(card.get("runtime_capabilities", {}).get("verified", []))
|
|
414
|
+
scaffold = list(card.get("runtime_capabilities", {}).get("scaffold_only", []))
|
|
415
|
+
for capability in verified + scaffold:
|
|
416
|
+
status = "verified" if capability in verified else "scaffold_only"
|
|
417
|
+
entries.append({
|
|
418
|
+
"id": f"runtime.{capability}",
|
|
419
|
+
"kind": capability_kind(capability),
|
|
420
|
+
"status": status,
|
|
421
|
+
"adapter_version": str(card.get("version", "unknown")),
|
|
422
|
+
"inputs": ["project-context", "motion-spec", "source-binding"],
|
|
423
|
+
"outputs": ["runtime-evidence"],
|
|
424
|
+
"compatibility": {"browsers": ["chromium"], "os": ["linux"], "node": ">=22"},
|
|
425
|
+
"last_verified_at": now(),
|
|
426
|
+
"evidence": [{"path": evidence_ref, "sha256": digest_file(evidence_path), "kind": evidence_kind}],
|
|
427
|
+
"limitations": ["Capability evidence is refreshed by runtime:test."] if status == "verified" else ["Scaffold only; do not use for production acceptance."],
|
|
428
|
+
"fallback": "runtime.lottie-json" if status == "scaffold_only" else "",
|
|
429
|
+
"risk_level": "high" if status == "scaffold_only" else "low",
|
|
430
|
+
"side_effect_level": "local_write",
|
|
431
|
+
})
|
|
432
|
+
registry = {
|
|
433
|
+
"schema_version": "0.1",
|
|
434
|
+
"registry_id": f"registry-{card.get('name', 'motionloom')}-{card.get('version', 'unknown')}",
|
|
435
|
+
"generated_at": now(),
|
|
436
|
+
"selection_policy": {"require_verified": True, "allow_scaffold_only": False, "max_evidence_age_seconds": 604800},
|
|
437
|
+
"capabilities": entries,
|
|
438
|
+
}
|
|
439
|
+
output = Path(args.output).expanduser().resolve() if args.output else ROOT / "capability-registry.json"
|
|
440
|
+
write_json(output, registry)
|
|
441
|
+
print(json.dumps({"status": "built", "kind": "capability-registry", "capability_count": len(entries), "path": str(output)}, ensure_ascii=False))
|
|
442
|
+
return 0
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def capability_validate(args: argparse.Namespace) -> int:
|
|
446
|
+
registry_path = Path(args.path).expanduser().resolve()
|
|
447
|
+
registry = read_json(registry_path)
|
|
448
|
+
if not isinstance(registry, dict) or not isinstance(registry.get("capabilities"), list):
|
|
449
|
+
raise ValueError("capability registry must contain capabilities")
|
|
450
|
+
ids = [entry.get("id") for entry in registry["capabilities"]]
|
|
451
|
+
if any(not value for value in ids) or len(ids) != len(set(ids)):
|
|
452
|
+
raise ValueError("capability registry contains duplicate or empty ids")
|
|
453
|
+
for entry in registry["capabilities"]:
|
|
454
|
+
for evidence in entry.get("evidence", []):
|
|
455
|
+
evidence_path = ROOT / safe_relative(str(evidence.get("path", "")))
|
|
456
|
+
if has_symlink_component(evidence_path) or not within(ROOT, evidence_path):
|
|
457
|
+
raise ValueError(f"capability evidence escapes repository: {evidence.get('path')}")
|
|
458
|
+
if not evidence_path.is_file():
|
|
459
|
+
raise ValueError(f"capability evidence missing: {evidence.get('path')}")
|
|
460
|
+
if digest_file(evidence_path) != evidence.get("sha256"):
|
|
461
|
+
raise ValueError(f"capability evidence hash mismatch: {evidence.get('path')}")
|
|
462
|
+
print(json.dumps({"status": "valid", "kind": "capability-registry", "capability_count": len(ids)}, ensure_ascii=False))
|
|
463
|
+
return 0
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def capability_select(args: argparse.Namespace) -> int:
|
|
467
|
+
registry = read_json(Path(args.registry).expanduser().resolve())
|
|
468
|
+
policy = registry.get("selection_policy", {})
|
|
469
|
+
requested_status = args.status
|
|
470
|
+
max_age = int(policy.get("max_evidence_age_seconds", 0) or 0)
|
|
471
|
+
current = datetime.now(timezone.utc)
|
|
472
|
+
selected = []
|
|
473
|
+
for entry in registry.get("capabilities", []):
|
|
474
|
+
if args.capability and entry.get("id") != args.capability:
|
|
475
|
+
continue
|
|
476
|
+
if requested_status and entry.get("status") != requested_status:
|
|
477
|
+
continue
|
|
478
|
+
if entry.get("status") == "scaffold_only" and not (args.allow_scaffold_only or policy.get("allow_scaffold_only")):
|
|
479
|
+
continue
|
|
480
|
+
if entry.get("status") == "verified" and max_age > 0:
|
|
481
|
+
try:
|
|
482
|
+
verified_at = datetime.fromisoformat(str(entry.get("last_verified_at", "")).replace("Z", "+00:00"))
|
|
483
|
+
except ValueError:
|
|
484
|
+
continue
|
|
485
|
+
if (current - verified_at).total_seconds() > max_age:
|
|
486
|
+
continue
|
|
487
|
+
evidence_ok = True
|
|
488
|
+
for evidence in entry.get("evidence", []):
|
|
489
|
+
try:
|
|
490
|
+
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"):
|
|
492
|
+
evidence_ok = False
|
|
493
|
+
break
|
|
494
|
+
except (OSError, ValueError):
|
|
495
|
+
evidence_ok = False
|
|
496
|
+
break
|
|
497
|
+
if not evidence_ok:
|
|
498
|
+
continue
|
|
499
|
+
selected.append(entry)
|
|
500
|
+
if not selected:
|
|
501
|
+
raise ValueError("no capability satisfies the registry selection policy")
|
|
502
|
+
print(json.dumps({"status": "selected", "count": len(selected), "capabilities": selected}, ensure_ascii=False))
|
|
503
|
+
return 0
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def motion_spec_path(task_dir: Path, scene: str, raw: str | None) -> Path:
|
|
507
|
+
options = []
|
|
508
|
+
if raw:
|
|
509
|
+
supplied = Path(raw).expanduser()
|
|
510
|
+
options.append(supplied if supplied.is_absolute() else task_dir / supplied)
|
|
511
|
+
options.extend([
|
|
512
|
+
task_dir / "motion-spec.json",
|
|
513
|
+
ROOT / "src" / "output" / scene / "motion-spec.json",
|
|
514
|
+
])
|
|
515
|
+
for option in options:
|
|
516
|
+
if option.resolve().is_file():
|
|
517
|
+
return option.resolve()
|
|
518
|
+
raise ValueError(f"missing motion-spec.json for scene: {scene}")
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def motion_ir_from_spec(task_dir: Path, task: dict[str, Any], spec: dict[str, Any], spec_path: Path) -> dict[str, Any]:
|
|
522
|
+
task_id = str(task.get("task_id", task_dir.name))
|
|
523
|
+
scene = str(task.get("scene", "scene"))
|
|
524
|
+
binding = spec.get("context_binding") or {}
|
|
525
|
+
context_hash = str(task.get("context_hash") or binding.get("context_sha256") or "")
|
|
526
|
+
if len(context_hash) != 64:
|
|
527
|
+
raise ValueError("Motion IR requires a SHA-256 context hash")
|
|
528
|
+
duration_s = float(spec.get("duration_s", 0))
|
|
529
|
+
fps = float(spec.get("fps", 0))
|
|
530
|
+
if duration_s <= 0 or fps <= 0:
|
|
531
|
+
raise ValueError("motion spec must contain positive duration_s and fps")
|
|
532
|
+
easing = str(spec.get("easing") or "linear")
|
|
533
|
+
reduced = str((spec.get("accessibility") or {}).get("reduced_motion") or "")
|
|
534
|
+
reduced_motion = reduced if reduced in {"none", "reduce", "replace", "freeze"} else "reduce"
|
|
535
|
+
source_ref = spec_path.resolve().relative_to(ROOT).as_posix() if within(ROOT, spec_path) else spec_path.name
|
|
536
|
+
return {
|
|
537
|
+
"schema_version": "0.1",
|
|
538
|
+
"ir_id": f"ir-{task_id}-{scene}",
|
|
539
|
+
"task_id": task_id,
|
|
540
|
+
"scene": scene,
|
|
541
|
+
"intent": str(task.get("intent") or f"Animate {spec.get('category', 'scene')}"),
|
|
542
|
+
"context_hash": context_hash,
|
|
543
|
+
"duration_ms": round(duration_s * 1000),
|
|
544
|
+
"fps": fps,
|
|
545
|
+
"tracks": [{
|
|
546
|
+
"id": "scene-progress",
|
|
547
|
+
"target": "scene.progress",
|
|
548
|
+
"property": "custom",
|
|
549
|
+
"keyframes": [
|
|
550
|
+
{"offset": 0, "value": 0, "easing": easing},
|
|
551
|
+
{"offset": 1, "value": 1, "easing": easing},
|
|
552
|
+
],
|
|
553
|
+
}],
|
|
554
|
+
"accessibility": {
|
|
555
|
+
"reduced_motion": reduced_motion,
|
|
556
|
+
"keyboard_safe": False,
|
|
557
|
+
"fallback_description": "Keyboard safety is not proven by the source motion spec; human review is required.",
|
|
558
|
+
},
|
|
559
|
+
"performance_budget": {
|
|
560
|
+
"max_layers": int((spec.get("performance") or {}).get("max_layers", 80)),
|
|
561
|
+
"allow_layout_animation": False,
|
|
562
|
+
},
|
|
563
|
+
"acceptance": [
|
|
564
|
+
{"id": "context-binding", "type": "deterministic", "expected": {"context_hash": context_hash}, "evidence_refs": ["task.json"]},
|
|
565
|
+
{"id": "runtime-checkpoints", "type": "runtime", "expected": {"checkpoints": [0, 50, 100]}, "evidence_refs": ["snapshot/frame-00.png", "snapshot/frame-50.png", "snapshot/frame-100.png"]},
|
|
566
|
+
{"id": "keyboard-safety", "type": "human_required", "expected": {"review": "explicit keyboard safety review"}, "evidence_refs": ["review.json"]},
|
|
567
|
+
],
|
|
568
|
+
"confidence": {"value": 0.65, "basis": "heuristic", "evidence_refs": [source_ref, "task.json"]},
|
|
569
|
+
"source_refs": [source_ref, "task.json"],
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def motion_ir_validate_data(ir: dict[str, Any]) -> list[str]:
|
|
574
|
+
issues = []
|
|
575
|
+
required = {"schema_version", "ir_id", "task_id", "scene", "intent", "context_hash", "duration_ms", "tracks", "accessibility", "acceptance"}
|
|
576
|
+
issues.extend(f"missing {field}" for field in sorted(required - set(ir)))
|
|
577
|
+
if ir.get("schema_version") != "0.1":
|
|
578
|
+
issues.append("schema_version must be 0.1")
|
|
579
|
+
if not isinstance(ir.get("context_hash"), str) or len(ir.get("context_hash", "")) != 64:
|
|
580
|
+
issues.append("context_hash must be a SHA-256 digest")
|
|
581
|
+
if not isinstance(ir.get("duration_ms"), int) or ir.get("duration_ms", 0) < 1:
|
|
582
|
+
issues.append("duration_ms must be a positive integer")
|
|
583
|
+
if not isinstance(ir.get("tracks"), list) or not ir.get("tracks"):
|
|
584
|
+
issues.append("tracks must contain at least one track")
|
|
585
|
+
else:
|
|
586
|
+
for track in ir["tracks"]:
|
|
587
|
+
keyframes = track.get("keyframes", []) if isinstance(track, dict) else []
|
|
588
|
+
offsets = [frame.get("offset") for frame in keyframes if isinstance(frame, dict)]
|
|
589
|
+
if len(keyframes) < 2 or offsets != sorted(offsets) or offsets[0] != 0 or offsets[-1] != 1:
|
|
590
|
+
issues.append(f"track {track.get('id', 'unnamed')} must span offsets 0..1")
|
|
591
|
+
accessibility = ir.get("accessibility") or {}
|
|
592
|
+
if accessibility.get("reduced_motion") not in {"none", "reduce", "replace", "freeze"}:
|
|
593
|
+
issues.append("accessibility.reduced_motion is invalid")
|
|
594
|
+
if not isinstance(accessibility.get("keyboard_safe"), bool):
|
|
595
|
+
issues.append("accessibility.keyboard_safe must be boolean")
|
|
596
|
+
acceptance = ir.get("acceptance")
|
|
597
|
+
if not isinstance(acceptance, list) or not acceptance:
|
|
598
|
+
issues.append("acceptance must contain at least one assertion")
|
|
599
|
+
else:
|
|
600
|
+
allowed = {"deterministic", "runtime", "human_required"}
|
|
601
|
+
for item in acceptance:
|
|
602
|
+
if item.get("type") not in allowed:
|
|
603
|
+
issues.append(f"invalid acceptance type: {item.get('type')}")
|
|
604
|
+
return issues
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def motion_ir_build(args: argparse.Namespace) -> int:
|
|
608
|
+
task_dir = task_dir_from(args.task_dir)
|
|
609
|
+
task = read_json(task_dir / "task.json")
|
|
610
|
+
if not isinstance(task, dict) or not task.get("task_id") or not task.get("scene"):
|
|
611
|
+
raise ValueError("task.json must contain task_id and scene")
|
|
612
|
+
spec_path = motion_spec_path(task_dir, str(task["scene"]), args.spec)
|
|
613
|
+
spec = read_json(spec_path)
|
|
614
|
+
ir = motion_ir_from_spec(task_dir, task, spec, spec_path)
|
|
615
|
+
issues = motion_ir_validate_data(ir)
|
|
616
|
+
if issues:
|
|
617
|
+
raise ValueError("generated Motion IR is invalid: " + "; ".join(issues))
|
|
618
|
+
output = Path(args.output).expanduser().resolve() if args.output else task_dir / "motion-ir.json"
|
|
619
|
+
write_json(output, ir)
|
|
620
|
+
print(json.dumps({"status": "built", "kind": "motion-ir", "task_id": ir["task_id"], "scene": ir["scene"], "track_count": len(ir["tracks"]), "path": str(output)}, ensure_ascii=False))
|
|
621
|
+
return 0
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def motion_ir_validate(args: argparse.Namespace) -> int:
|
|
625
|
+
path = Path(args.path).expanduser().resolve()
|
|
626
|
+
ir = read_json(path)
|
|
627
|
+
issues = motion_ir_validate_data(ir if isinstance(ir, dict) else {})
|
|
628
|
+
if issues:
|
|
629
|
+
raise ValueError("Motion IR invalid: " + "; ".join(issues))
|
|
630
|
+
print(json.dumps({"status": "valid", "kind": "motion-ir", "task_id": ir["task_id"], "scene": ir["scene"], "track_count": len(ir["tracks"])}, ensure_ascii=False))
|
|
631
|
+
return 0
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def finding(
|
|
635
|
+
finding_id: str,
|
|
636
|
+
rule_id: str,
|
|
637
|
+
category: str,
|
|
638
|
+
severity: str,
|
|
639
|
+
confidence: float,
|
|
640
|
+
basis: str,
|
|
641
|
+
message: str,
|
|
642
|
+
evidence_refs: list[str],
|
|
643
|
+
affected_paths: list[str],
|
|
644
|
+
suggested_action: str = "",
|
|
645
|
+
rationale: str = "",
|
|
646
|
+
approval_blocking: bool = False,
|
|
647
|
+
) -> dict[str, Any]:
|
|
648
|
+
return {
|
|
649
|
+
"id": finding_id,
|
|
650
|
+
"rule_id": rule_id,
|
|
651
|
+
"category": category,
|
|
652
|
+
"severity": severity,
|
|
653
|
+
"confidence": round(max(0.0, min(1.0, confidence)), 3),
|
|
654
|
+
"basis": basis,
|
|
655
|
+
"message": message,
|
|
656
|
+
"rationale": rationale,
|
|
657
|
+
"evidence_refs": evidence_refs,
|
|
658
|
+
"affected_paths": affected_paths,
|
|
659
|
+
"suggested_action": suggested_action,
|
|
660
|
+
"approval_blocking": approval_blocking,
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def semantic_lint_data(task_dir: Path, task: dict[str, Any], ir: dict[str, Any], spec: dict[str, Any]) -> dict[str, Any]:
|
|
665
|
+
task_id = str(task.get("task_id", task_dir.name))
|
|
666
|
+
scene = str(task.get("scene", ir.get("scene", "scene")))
|
|
667
|
+
evidence_refs = ["task.json", "motion-ir.json", "motion-spec.json"]
|
|
668
|
+
findings: list[dict[str, Any]] = []
|
|
669
|
+
duration_s = float(spec.get("duration_s", 0) or 0)
|
|
670
|
+
fps = float(spec.get("fps", 0) or 0)
|
|
671
|
+
expected_duration_ms = round(duration_s * 1000)
|
|
672
|
+
category = str(spec.get("category") or task.get("category") or "").strip().lower()
|
|
673
|
+
ui_context = category in UI_CONTEXT_CATEGORIES or any(token in str(ir.get("intent", "")).lower() for token in ("ui", "button", "dialog", "loading", "menu"))
|
|
674
|
+
if expected_duration_ms and ir.get("duration_ms") != expected_duration_ms:
|
|
675
|
+
findings.append(finding(
|
|
676
|
+
"timing-duration-mismatch", "MOTION.TIMING.DURATION", "timing", "error", 0.99, "deterministic",
|
|
677
|
+
"Motion IR duration does not match the source motion spec.", evidence_refs, ["motion-ir.json", "motion-spec.json"],
|
|
678
|
+
"Rebuild Motion IR from the bound motion-spec.json before rendering.",
|
|
679
|
+
f"Expected {expected_duration_ms} ms from duration_s={duration_s}; received {ir.get('duration_ms')}.", True,
|
|
680
|
+
))
|
|
681
|
+
if fps and float(ir.get("fps", 0) or 0) != fps:
|
|
682
|
+
findings.append(finding(
|
|
683
|
+
"timing-fps-mismatch", "MOTION.TIMING.FPS", "timing", "error", 0.99, "deterministic",
|
|
684
|
+
"Motion IR FPS does not match the source motion spec.", evidence_refs, ["motion-ir.json", "motion-spec.json"],
|
|
685
|
+
"Rebuild Motion IR from the bound motion-spec.json before rendering.",
|
|
686
|
+
f"Expected fps={fps}; received {ir.get('fps')}.", True,
|
|
687
|
+
))
|
|
688
|
+
|
|
689
|
+
duration_ms = int(ir.get("duration_ms", 0) or 0)
|
|
690
|
+
if ui_context and duration_ms > 500:
|
|
691
|
+
findings.append(finding(
|
|
692
|
+
"perf-animation-budget", "MOTION.PERF.ANIMATION_BUDGET", "performance", "warning", 0.75, "heuristic",
|
|
693
|
+
"UI-context animation exceeds the default 500 ms interaction budget.", evidence_refs, ["motion-ir.json", "motion-spec.json"],
|
|
694
|
+
"Split the motion into a short interaction response and an optional secondary flourish, or document why the longer duration is intentional.",
|
|
695
|
+
f"UI category={category or 'unknown'}; duration_ms={duration_ms}; default_budget_ms=500.", False,
|
|
696
|
+
))
|
|
697
|
+
if float(ir.get("fps", 0) or 0) < 30:
|
|
698
|
+
findings.append(finding(
|
|
699
|
+
"perf-frame-rate", "MOTION.PERF.FRAME_RATE", "performance", "warning", 0.85, "deterministic",
|
|
700
|
+
"Motion IR FPS is below the 30 FPS perceptual floor used by the lint contract.", ["motion-ir.json"], ["motion-ir.json"],
|
|
701
|
+
"Raise the authored frame rate to at least 30 FPS or attach runtime evidence explaining the lower rate.",
|
|
702
|
+
f"fps={ir.get('fps')}; minimum_fps=30.", False,
|
|
703
|
+
))
|
|
704
|
+
track_count = len(ir.get("tracks", []))
|
|
705
|
+
if track_count > 10:
|
|
706
|
+
findings.append(finding(
|
|
707
|
+
"perf-track-count", "MOTION.PERF.TRACK_COUNT", "performance", "info", 0.70, "heuristic",
|
|
708
|
+
"Motion IR contains more than ten tracks and may require a runtime complexity review.", ["motion-ir.json"], ["motion-ir.json"],
|
|
709
|
+
"Confirm that the track count is justified by runtime evidence and does not cause unnecessary compositing work.",
|
|
710
|
+
f"track_count={track_count}; review_threshold=10.", False,
|
|
711
|
+
))
|
|
712
|
+
|
|
713
|
+
intent = str(ir.get("intent", "")).strip().lower()
|
|
714
|
+
generic_intents = {"animation task intent", "animate scene", "animate loading", "motion"}
|
|
715
|
+
if not intent:
|
|
716
|
+
findings.append(finding(
|
|
717
|
+
"intent-missing", "MOTION.INTENT.PRESENT", "intent", "error", 0.99, "deterministic",
|
|
718
|
+
"Motion IR has no usable intent statement.", ["motion-ir.json", "task.json"], ["motion-ir.json"],
|
|
719
|
+
"Populate task intent with the user-visible motion outcome and rebuild Motion IR.",
|
|
720
|
+
"Semantic lint cannot compare behavior to an absent intent.", True,
|
|
721
|
+
))
|
|
722
|
+
elif intent in generic_intents or len(intent.split()) < 2:
|
|
723
|
+
findings.append(finding(
|
|
724
|
+
"intent-low-specificity", "MOTION.INTENT.SPECIFIC", "intent", "warning", 0.86, "heuristic",
|
|
725
|
+
"Motion intent is too generic to support reliable semantic review.", ["motion-ir.json", "task.json"], ["motion-ir.json", "task.json"],
|
|
726
|
+
"Rewrite intent as observable behavior, trigger and user-facing outcome.",
|
|
727
|
+
"A generic label can pass structural validation while still causing the Agent to select the wrong motion treatment.", False,
|
|
728
|
+
))
|
|
729
|
+
|
|
730
|
+
known_easings = {"linear", "ease-in", "ease-out", "ease-in-out", "spring", "step-start", "step-end"}
|
|
731
|
+
linear_tracks: list[str] = []
|
|
732
|
+
for track in ir.get("tracks", []):
|
|
733
|
+
for index, keyframe in enumerate(track.get("keyframes", [])):
|
|
734
|
+
easing = keyframe.get("easing")
|
|
735
|
+
if easing and str(easing).lower() not in known_easings:
|
|
736
|
+
findings.append(finding(
|
|
737
|
+
f"easing-unknown-{track.get('id', 'track')}-{index}", "MOTION.EASING.KNOWN", "easing", "warning", 0.92, "deterministic",
|
|
738
|
+
f"Track {track.get('id', 'unnamed')} uses an easing not covered by the canonical registry.", ["motion-ir.json"], ["motion-ir.json"],
|
|
739
|
+
"Map the easing to a canonical name or document the runtime-specific curve in the adapter contract.",
|
|
740
|
+
f"Unknown easing: {easing}.", False,
|
|
741
|
+
))
|
|
742
|
+
if ui_context and str(easing).lower() == "linear" and str(track.get("id", "unnamed")) not in linear_tracks:
|
|
743
|
+
linear_tracks.append(str(track.get("id", "unnamed")))
|
|
744
|
+
if linear_tracks:
|
|
745
|
+
findings.append(finding(
|
|
746
|
+
"perceptual-easing-linear", "MOTION.PERCEPTUAL.EASING", "easing", "warning", 0.78, "heuristic",
|
|
747
|
+
"UI-context motion uses linear easing, which can read as mechanical rather than responsive.", ["motion-ir.json", "motion-spec.json"], ["motion-ir.json"],
|
|
748
|
+
"Prefer a documented ease-out, ease-in-out, spring or runtime-specific curve unless linear timing is part of the interaction intent.",
|
|
749
|
+
f"linear_tracks={linear_tracks}; category={category or 'unknown'}.", False,
|
|
750
|
+
))
|
|
751
|
+
|
|
752
|
+
accessibility = ir.get("accessibility") or {}
|
|
753
|
+
reduced_motion = accessibility.get("reduced_motion")
|
|
754
|
+
if reduced_motion is None:
|
|
755
|
+
findings.append(finding(
|
|
756
|
+
"accessibility-reduced-motion", "MOTION.A11Y.REDUCED_MOTION", "accessibility", "error", 0.98, "deterministic",
|
|
757
|
+
"Motion IR does not declare a reduced-motion behavior that the runtime can enforce.", ["motion-ir.json", "motion-spec.json"], ["motion-ir.json", "motion-spec.json"],
|
|
758
|
+
"Declare reduce, replace or freeze behavior and verify it in runtime evidence.",
|
|
759
|
+
"A missing or none policy leaves high-motion behavior without an explicit fallback.", True,
|
|
760
|
+
))
|
|
761
|
+
elif reduced_motion == "none":
|
|
762
|
+
findings.append(finding(
|
|
763
|
+
"perceptual-reduced-motion-missing", "MOTION.PERCEPTUAL.REDUCED_MOTION", "accessibility", "warning", 0.90, "deterministic",
|
|
764
|
+
"Motion IR explicitly disables reduced-motion handling for a user-facing animation.", ["motion-ir.json", "motion-spec.json"], ["motion-ir.json", "motion-spec.json"],
|
|
765
|
+
"Declare reduce, replace or freeze behavior and verify the fallback in runtime evidence.",
|
|
766
|
+
"The policy is structurally present but does not provide a reduced-motion outcome.", False,
|
|
767
|
+
))
|
|
768
|
+
elif reduced_motion not in {"reduce", "replace", "freeze"}:
|
|
769
|
+
findings.append(finding(
|
|
770
|
+
"accessibility-reduced-motion", "MOTION.A11Y.REDUCED_MOTION", "accessibility", "error", 0.98, "deterministic",
|
|
771
|
+
"Motion IR declares a reduced-motion policy that is not supported by the canonical runtime contract.", ["motion-ir.json", "motion-spec.json"], ["motion-ir.json", "motion-spec.json"],
|
|
772
|
+
"Declare reduce, replace or freeze behavior and verify it in runtime evidence.",
|
|
773
|
+
f"Unsupported reduced_motion policy: {reduced_motion}.", True,
|
|
774
|
+
))
|
|
775
|
+
if accessibility.get("keyboard_safe") is False:
|
|
776
|
+
findings.append(finding(
|
|
777
|
+
"accessibility-keyboard-review", "MOTION.A11Y.KEYBOARD_REVIEW", "accessibility", "warning", 0.97, "human",
|
|
778
|
+
"Keyboard safety is not proven by the current Motion IR and remains a human-review item.", ["motion-ir.json", "review.json"], ["motion-ir.json", "review.json"],
|
|
779
|
+
"Keep the human-required acceptance assertion and record the reviewer decision in review.json.",
|
|
780
|
+
"The linter does not infer keyboard safety from animation data.", False,
|
|
781
|
+
))
|
|
782
|
+
|
|
783
|
+
budget = ir.get("performance_budget") or {}
|
|
784
|
+
max_tracks = budget.get("max_tracks")
|
|
785
|
+
if isinstance(max_tracks, int) and len(ir.get("tracks", [])) > max_tracks:
|
|
786
|
+
findings.append(finding(
|
|
787
|
+
"performance-track-budget", "MOTION.PERF.MAX_TRACKS", "performance", "error", 0.99, "deterministic",
|
|
788
|
+
"Motion IR exceeds its declared track budget.", ["motion-ir.json"], ["motion-ir.json"],
|
|
789
|
+
"Reduce track count or update the budget with project-context evidence and a reviewed reason.",
|
|
790
|
+
f"Track count={len(ir.get('tracks', []))}; max_tracks={max_tracks}.", True,
|
|
791
|
+
))
|
|
792
|
+
if not ir.get("acceptance"):
|
|
793
|
+
findings.append(finding(
|
|
794
|
+
"acceptance-missing", "MOTION.ACCEPTANCE.PRESENT", "intent", "error", 0.99, "deterministic",
|
|
795
|
+
"Motion IR has no acceptance assertions for semantic or runtime verification.", ["motion-ir.json"], ["motion-ir.json"],
|
|
796
|
+
"Add deterministic, runtime or human_required acceptance assertions.",
|
|
797
|
+
"Without acceptance assertions the Agent cannot distinguish generated output from verified output.", True,
|
|
798
|
+
))
|
|
799
|
+
|
|
800
|
+
errors = sum(item["severity"] == "error" for item in findings)
|
|
801
|
+
warnings = sum(item["severity"] == "warning" for item in findings)
|
|
802
|
+
infos = sum(item["severity"] == "info" for item in findings)
|
|
803
|
+
blocking = sum(bool(item["approval_blocking"]) for item in findings)
|
|
804
|
+
return {
|
|
805
|
+
"schema_version": "0.1",
|
|
806
|
+
"report_id": f"lint-{task_id}-{scene}",
|
|
807
|
+
"task_id": task_id,
|
|
808
|
+
"scene": scene,
|
|
809
|
+
"status": "fail" if blocking else ("warn" if warnings else "pass"),
|
|
810
|
+
"ruleset": {"id": "motionloom.semantic-motion", "version": "0.2"},
|
|
811
|
+
"context_hash": task.get("context_hash") or ir.get("context_hash"),
|
|
812
|
+
"motion_ir": {"path": "motion-ir.json", "sha256": digest_file(task_dir / "motion-ir.json")},
|
|
813
|
+
"summary": {"total": len(findings), "errors": errors, "warnings": warnings, "infos": infos, "blocking": blocking},
|
|
814
|
+
"findings": findings,
|
|
815
|
+
"generated_at": now(),
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def semantic_lint_validate_data(report: dict[str, Any]) -> list[str]:
|
|
820
|
+
issues: list[str] = []
|
|
821
|
+
required = {"schema_version", "report_id", "task_id", "scene", "status", "ruleset", "summary", "findings", "generated_at"}
|
|
822
|
+
issues.extend(f"missing {field}" for field in sorted(required - set(report)))
|
|
823
|
+
if report.get("schema_version") != "0.1":
|
|
824
|
+
issues.append("schema_version must be 0.1")
|
|
825
|
+
if report.get("status") not in {"pass", "warn", "fail"}:
|
|
826
|
+
issues.append("status must be pass, warn or fail")
|
|
827
|
+
summary = report.get("summary") or {}
|
|
828
|
+
findings = report.get("findings") or []
|
|
829
|
+
if summary.get("total") != len(findings):
|
|
830
|
+
issues.append("summary.total does not match findings length")
|
|
831
|
+
blocking = sum(bool(item.get("approval_blocking")) for item in findings if isinstance(item, dict))
|
|
832
|
+
if summary.get("blocking") != blocking:
|
|
833
|
+
issues.append("summary.blocking does not match findings")
|
|
834
|
+
if report.get("status") == "fail" and blocking == 0:
|
|
835
|
+
issues.append("fail status requires at least one blocking finding")
|
|
836
|
+
return issues
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def semantic_lint_benchmark_data(
|
|
840
|
+
task_dir: Path,
|
|
841
|
+
task: dict[str, Any],
|
|
842
|
+
ir: dict[str, Any],
|
|
843
|
+
spec: dict[str, Any],
|
|
844
|
+
iterations: int,
|
|
845
|
+
threshold_ms: float,
|
|
846
|
+
) -> dict[str, Any]:
|
|
847
|
+
if iterations < 1:
|
|
848
|
+
raise ValueError("benchmark iterations must be positive")
|
|
849
|
+
if threshold_ms <= 0:
|
|
850
|
+
raise ValueError("benchmark threshold_ms must be positive")
|
|
851
|
+
samples_ms: list[float] = []
|
|
852
|
+
representative: dict[str, Any] = {}
|
|
853
|
+
for _ in range(iterations):
|
|
854
|
+
started = time.perf_counter_ns()
|
|
855
|
+
representative = semantic_lint_data(task_dir, task, ir, spec)
|
|
856
|
+
samples_ms.append((time.perf_counter_ns() - started) / 1_000_000)
|
|
857
|
+
ordered = sorted(samples_ms)
|
|
858
|
+
p95_index = min(len(ordered) - 1, max(0, math.ceil(len(ordered) * 0.95) - 1))
|
|
859
|
+
p95_ms = ordered[p95_index]
|
|
860
|
+
average_ms = sum(samples_ms) / len(samples_ms)
|
|
861
|
+
return {
|
|
862
|
+
"schema_version": "0.1",
|
|
863
|
+
"benchmark_id": f"semantic-lint-benchmark-{task.get('task_id', task_dir.name)}-{task.get('scene', 'scene')}",
|
|
864
|
+
"task_id": str(task.get("task_id", task_dir.name)),
|
|
865
|
+
"scene": str(task.get("scene", ir.get("scene", "scene"))),
|
|
866
|
+
"operation": "semantic-lint-build",
|
|
867
|
+
"iterations": iterations,
|
|
868
|
+
"threshold_ms": round(float(threshold_ms), 3),
|
|
869
|
+
"samples_ms": [round(sample, 3) for sample in samples_ms],
|
|
870
|
+
"average_ms": round(average_ms, 3),
|
|
871
|
+
"p95_ms": round(p95_ms, 3),
|
|
872
|
+
"status": "pass" if p95_ms < threshold_ms else "fail",
|
|
873
|
+
"rule_count": len(SEMANTIC_RULE_IDS),
|
|
874
|
+
"rule_ids": list(SEMANTIC_RULE_IDS),
|
|
875
|
+
"lint_status": representative.get("status"),
|
|
876
|
+
"lint_finding_count": len(representative.get("findings", [])),
|
|
877
|
+
"generated_at": now(),
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def semantic_lint_benchmark_validate_data(report: dict[str, Any]) -> list[str]:
|
|
882
|
+
required = {
|
|
883
|
+
"schema_version", "benchmark_id", "task_id", "scene", "operation", "iterations",
|
|
884
|
+
"threshold_ms", "samples_ms", "average_ms", "p95_ms", "status", "rule_count", "rule_ids", "generated_at",
|
|
885
|
+
}
|
|
886
|
+
issues = [f"missing {field}" for field in sorted(required - set(report))]
|
|
887
|
+
if report.get("schema_version") != "0.1":
|
|
888
|
+
issues.append("schema_version must be 0.1")
|
|
889
|
+
if report.get("operation") != "semantic-lint-build":
|
|
890
|
+
issues.append("operation must be semantic-lint-build")
|
|
891
|
+
if not isinstance(report.get("iterations"), int) or report.get("iterations", 0) < 1:
|
|
892
|
+
issues.append("iterations must be a positive integer")
|
|
893
|
+
samples = report.get("samples_ms")
|
|
894
|
+
if not isinstance(samples, list) or len(samples) != report.get("iterations"):
|
|
895
|
+
issues.append("samples_ms length must match iterations")
|
|
896
|
+
if report.get("status") not in {"pass", "fail"}:
|
|
897
|
+
issues.append("status must be pass or fail")
|
|
898
|
+
if report.get("rule_count") != len(report.get("rule_ids", [])):
|
|
899
|
+
issues.append("rule_count must match rule_ids length")
|
|
900
|
+
return issues
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def semantic_lint_benchmark(args: argparse.Namespace) -> int:
|
|
904
|
+
task_dir = task_dir_from(args.task_dir)
|
|
905
|
+
task = read_json(task_dir / "task.json")
|
|
906
|
+
ir = read_json(task_dir / "motion-ir.json")
|
|
907
|
+
spec_path = motion_spec_path(task_dir, str(task.get("scene", "scene")), args.spec)
|
|
908
|
+
spec = read_json(spec_path)
|
|
909
|
+
report = semantic_lint_benchmark_data(task_dir, task, ir, spec, args.iterations, args.threshold_ms)
|
|
910
|
+
issues = semantic_lint_benchmark_validate_data(report)
|
|
911
|
+
if issues:
|
|
912
|
+
raise ValueError("semantic lint benchmark is invalid: " + "; ".join(issues))
|
|
913
|
+
output = Path(args.output).expanduser().resolve() if args.output else task_dir / "semantic-lint-benchmark.json"
|
|
914
|
+
write_json(output, report)
|
|
915
|
+
print(json.dumps({
|
|
916
|
+
"status": report["status"], "kind": "semantic-lint-benchmark", "task_id": report["task_id"],
|
|
917
|
+
"scene": report["scene"], "p95_ms": report["p95_ms"], "threshold_ms": report["threshold_ms"],
|
|
918
|
+
"rule_count": report["rule_count"], "path": str(output),
|
|
919
|
+
}, ensure_ascii=False))
|
|
920
|
+
return 0 if report["status"] == "pass" else 1
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def validate_task_benchmark(task_dir: Path, scene: str | None = None) -> list[str]:
|
|
924
|
+
"""Validate an optional semantic-lint benchmark artifact without side effects."""
|
|
925
|
+
issues: list[str] = []
|
|
926
|
+
path = task_dir / "semantic-lint-benchmark.json"
|
|
927
|
+
if not path.is_file():
|
|
928
|
+
return ["missing semantic-lint-benchmark.json"]
|
|
929
|
+
try:
|
|
930
|
+
task = read_json(task_dir / "task.json")
|
|
931
|
+
report = read_json(path)
|
|
932
|
+
issues.extend(semantic_lint_benchmark_validate_data(report))
|
|
933
|
+
if report.get("task_id") != task.get("task_id"):
|
|
934
|
+
issues.append("semantic lint benchmark task_id does not match task.json")
|
|
935
|
+
if scene and report.get("scene") != scene:
|
|
936
|
+
issues.append("semantic lint benchmark scene does not match quality-gate scene")
|
|
937
|
+
if report.get("status") != "pass":
|
|
938
|
+
issues.append("semantic lint benchmark status must be pass")
|
|
939
|
+
if float(report.get("p95_ms", 0) or 0) >= float(report.get("threshold_ms", 0) or 0):
|
|
940
|
+
issues.append("semantic lint benchmark p95_ms must be below threshold_ms")
|
|
941
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
942
|
+
issues.append(f"benchmark validation error: {exc}")
|
|
943
|
+
return issues
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def semantic_lint_build(args: argparse.Namespace) -> int:
|
|
947
|
+
task_dir = task_dir_from(args.task_dir)
|
|
948
|
+
task = read_json(task_dir / "task.json")
|
|
949
|
+
ir = read_json(task_dir / "motion-ir.json")
|
|
950
|
+
spec_path = motion_spec_path(task_dir, str(task.get("scene", "scene")), args.spec)
|
|
951
|
+
spec = read_json(spec_path)
|
|
952
|
+
report = semantic_lint_data(task_dir, task, ir, spec)
|
|
953
|
+
issues = semantic_lint_validate_data(report)
|
|
954
|
+
if issues:
|
|
955
|
+
raise ValueError("semantic lint report is invalid: " + "; ".join(issues))
|
|
956
|
+
output = Path(args.output).expanduser().resolve() if args.output else task_dir / "semantic-lint-report.json"
|
|
957
|
+
write_json(output, report)
|
|
958
|
+
print(json.dumps({"status": "built", "kind": "semantic-lint-report", "task_id": report["task_id"], "scene": report["scene"], "lint_status": report["status"], "finding_count": len(report["findings"]), "path": str(output)}, ensure_ascii=False))
|
|
959
|
+
return 0
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def semantic_lint_validate(args: argparse.Namespace) -> int:
|
|
963
|
+
path = Path(args.path).expanduser().resolve()
|
|
964
|
+
report = read_json(path)
|
|
965
|
+
issues = semantic_lint_validate_data(report if isinstance(report, dict) else {})
|
|
966
|
+
if issues:
|
|
967
|
+
raise ValueError("semantic lint report invalid: " + "; ".join(issues))
|
|
968
|
+
print(json.dumps({"status": "valid", "kind": "semantic-lint-report", "task_id": report.get("task_id"), "scene": report.get("scene"), "lint_status": report.get("status"), "finding_count": len(report.get("findings", []))}, ensure_ascii=False))
|
|
969
|
+
return 0
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def continuity_report_data(task_dirs: list[Path], project_id: str | None = None) -> dict[str, Any]:
|
|
973
|
+
if not task_dirs:
|
|
974
|
+
raise ValueError("at least one task directory is required")
|
|
975
|
+
entries = []
|
|
976
|
+
seen_scenes: set[str] = set()
|
|
977
|
+
for task_dir in task_dirs:
|
|
978
|
+
task = read_json(task_dir / "task.json")
|
|
979
|
+
ir = read_json(task_dir / "motion-ir.json")
|
|
980
|
+
scene = str(task.get("scene") or ir.get("scene") or task_dir.name)
|
|
981
|
+
if scene in seen_scenes:
|
|
982
|
+
raise ValueError(f"duplicate scene in continuity input: {scene}")
|
|
983
|
+
seen_scenes.add(scene)
|
|
984
|
+
if task.get("task_id") != ir.get("task_id") or scene != ir.get("scene"):
|
|
985
|
+
raise ValueError(f"task/Motion IR identity mismatch for scene: {scene}")
|
|
986
|
+
order = task.get("scene_order")
|
|
987
|
+
entries.append({"task_dir": task_dir, "task": task, "ir": ir, "scene": scene, "order": order if isinstance(order, int) else 0})
|
|
988
|
+
entries.sort(key=lambda item: (item["order"], item["scene"]))
|
|
989
|
+
scenes = []
|
|
990
|
+
for index, item in enumerate(entries):
|
|
991
|
+
ir_path = item["task_dir"] / "motion-ir.json"
|
|
992
|
+
scenes.append({
|
|
993
|
+
"scene": item["scene"],
|
|
994
|
+
"order": index,
|
|
995
|
+
"motion_ir_path": str(ir_path),
|
|
996
|
+
"motion_ir_sha256": digest_file(ir_path),
|
|
997
|
+
"context_hash": str(item["task"].get("context_hash") or item["ir"].get("context_hash")),
|
|
998
|
+
"intent": str(item["ir"].get("intent", "")).strip(),
|
|
999
|
+
})
|
|
1000
|
+
transitions = []
|
|
1001
|
+
errors = 0
|
|
1002
|
+
warnings = 0
|
|
1003
|
+
for previous, current in zip(entries, entries[1:]):
|
|
1004
|
+
previous_ir = previous["ir"]
|
|
1005
|
+
current_ir = current["ir"]
|
|
1006
|
+
checks = ["scene identity", "intent present", "context binding", "fps compatibility", "duration continuity"]
|
|
1007
|
+
findings: list[str] = []
|
|
1008
|
+
status = "pass"
|
|
1009
|
+
if not str(current_ir.get("intent", "")).strip() or not str(previous_ir.get("intent", "")).strip():
|
|
1010
|
+
findings.append("missing intent at transition boundary")
|
|
1011
|
+
status = "fail"
|
|
1012
|
+
previous_context = previous_ir.get("context_hash")
|
|
1013
|
+
current_context = current_ir.get("context_hash")
|
|
1014
|
+
if previous_context != current_context:
|
|
1015
|
+
findings.append("context hash changes between adjacent scenes")
|
|
1016
|
+
status = "warn"
|
|
1017
|
+
if float(previous_ir.get("fps", 0) or 0) != float(current_ir.get("fps", 0) or 0):
|
|
1018
|
+
findings.append("fps changes between adjacent scenes")
|
|
1019
|
+
status = "warn"
|
|
1020
|
+
if int(previous_ir.get("duration_ms", 0) or 0) <= 0 or int(current_ir.get("duration_ms", 0) or 0) <= 0:
|
|
1021
|
+
findings.append("non-positive duration at transition boundary")
|
|
1022
|
+
status = "fail"
|
|
1023
|
+
if status == "fail":
|
|
1024
|
+
errors += len(findings)
|
|
1025
|
+
elif status == "warn":
|
|
1026
|
+
warnings += len(findings)
|
|
1027
|
+
transitions.append({"id": f"transition-{previous['scene']}-{current['scene']}", "from_scene": previous["scene"], "to_scene": current["scene"], "status": status, "checks": checks, "findings": findings})
|
|
1028
|
+
status = "fail" if errors else ("warn" if warnings else "pass")
|
|
1029
|
+
return {
|
|
1030
|
+
"schema_version": "0.1",
|
|
1031
|
+
"report_id": f"continuity-{project_id or entries[0]['task'].get('project_name') or entries[0]['task'].get('task_id')}",
|
|
1032
|
+
"project_id": project_id or str(entries[0]["task"].get("project_name") or entries[0]["task"].get("task_id")),
|
|
1033
|
+
"status": status,
|
|
1034
|
+
"scenes": scenes,
|
|
1035
|
+
"transitions": transitions,
|
|
1036
|
+
"summary": {"scene_count": len(scenes), "transition_count": len(transitions), "errors": errors, "warnings": warnings, "blocking": errors},
|
|
1037
|
+
"generated_at": now(),
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def continuity_validate_data(report: dict[str, Any]) -> list[str]:
|
|
1042
|
+
issues: list[str] = []
|
|
1043
|
+
if report.get("schema_version") != "0.1":
|
|
1044
|
+
issues.append("schema_version must be 0.1")
|
|
1045
|
+
scenes = report.get("scenes") or []
|
|
1046
|
+
transitions = report.get("transitions") or []
|
|
1047
|
+
if not scenes:
|
|
1048
|
+
issues.append("continuity report requires at least one scene")
|
|
1049
|
+
if len(transitions) != max(0, len(scenes) - 1):
|
|
1050
|
+
issues.append("transition count must equal scene count minus one")
|
|
1051
|
+
if report.get("summary", {}).get("scene_count") != len(scenes):
|
|
1052
|
+
issues.append("summary.scene_count does not match scenes")
|
|
1053
|
+
return issues
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def continuity_build(args: argparse.Namespace) -> int:
|
|
1057
|
+
task_dirs = [task_dir_from(value) for value in args.task_dirs]
|
|
1058
|
+
report = continuity_report_data(task_dirs, args.project_id)
|
|
1059
|
+
issues = continuity_validate_data(report)
|
|
1060
|
+
if issues:
|
|
1061
|
+
raise ValueError("continuity report is invalid: " + "; ".join(issues))
|
|
1062
|
+
output = Path(args.output).expanduser().resolve() if args.output else task_dirs[0] / "continuity-report.json"
|
|
1063
|
+
write_json(output, report)
|
|
1064
|
+
print(json.dumps({"status": "built", "kind": "continuity-report", "project_id": report["project_id"], "scene_count": len(report["scenes"]), "transition_count": len(report["transitions"]), "continuity_status": report["status"], "path": str(output)}, ensure_ascii=False))
|
|
1065
|
+
return 0
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def continuity_validate(args: argparse.Namespace) -> int:
|
|
1069
|
+
path = Path(args.path).expanduser().resolve()
|
|
1070
|
+
report = read_json(path)
|
|
1071
|
+
issues = continuity_validate_data(report if isinstance(report, dict) else {})
|
|
1072
|
+
if issues:
|
|
1073
|
+
raise ValueError("continuity report invalid: " + "; ".join(issues))
|
|
1074
|
+
print(json.dumps({"status": "valid", "kind": "continuity-report", "project_id": report.get("project_id"), "continuity_status": report.get("status"), "transition_count": len(report.get("transitions", []))}, ensure_ascii=False))
|
|
1075
|
+
return 0
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def fix_plan_build(args: argparse.Namespace) -> int:
|
|
1079
|
+
task_dir = task_dir_from(args.task_dir)
|
|
1080
|
+
reports = []
|
|
1081
|
+
issues = []
|
|
1082
|
+
for raw_path in args.reports:
|
|
1083
|
+
path = Path(raw_path).expanduser()
|
|
1084
|
+
path = (task_dir / path if not path.is_absolute() else path).resolve()
|
|
1085
|
+
if not within(task_dir, path) or not path.is_file():
|
|
1086
|
+
raise ValueError(f"fix-plan report must be a file inside task bundle: {raw_path}")
|
|
1087
|
+
data = read_json(path)
|
|
1088
|
+
kind = "semantic-lint" if "findings" in data else "continuity" if "transitions" in data else "quality"
|
|
1089
|
+
reports.append({"path": path.relative_to(task_dir).as_posix(), "sha256": digest_file(path), "kind": kind})
|
|
1090
|
+
for item in data.get("findings", []):
|
|
1091
|
+
if item.get("severity") == "info":
|
|
1092
|
+
continue
|
|
1093
|
+
issue_id = f"fix-{item.get('id', len(issues) + 1)}"
|
|
1094
|
+
category = item.get("category", "continuity")
|
|
1095
|
+
rerun = ["lint"]
|
|
1096
|
+
if category == "continuity":
|
|
1097
|
+
rerun = ["continuity", "quality_gate"]
|
|
1098
|
+
elif category in {"timing", "easing", "performance"}:
|
|
1099
|
+
rerun = ["lint", "render", "runtime", "replay", "quality_gate"]
|
|
1100
|
+
elif category == "accessibility":
|
|
1101
|
+
rerun = ["lint", "runtime", "browser_review", "quality_gate"]
|
|
1102
|
+
issues.append({
|
|
1103
|
+
"id": issue_id,
|
|
1104
|
+
"finding_ref": f"{path.relative_to(task_dir).as_posix()}#{item.get('id', issue_id)}",
|
|
1105
|
+
"severity": item.get("severity", "warning"),
|
|
1106
|
+
"confidence": item.get("confidence", 0.5),
|
|
1107
|
+
"root_cause": item.get("rationale") or item.get("message", "Unspecified semantic finding"),
|
|
1108
|
+
"affected_artifacts": item.get("affected_paths", []),
|
|
1109
|
+
"patch_scope": [item.get("suggested_action") or "Inspect the finding and update the bound artifact."],
|
|
1110
|
+
"rerun_scope": rerun,
|
|
1111
|
+
"verification": [f"python3 scripts/intelligence.py semantic-lint validate --path {path.relative_to(task_dir).as_posix()}", "python3 scripts/report.py check --task-dir <task-dir>"],
|
|
1112
|
+
"requires_user_review": bool(item.get("approval_blocking")) or category == "accessibility",
|
|
1113
|
+
"status": "open",
|
|
1114
|
+
})
|
|
1115
|
+
plan = {
|
|
1116
|
+
"schema_version": "0.1",
|
|
1117
|
+
"plan_id": f"fix-plan-{task_dir.name}",
|
|
1118
|
+
"task_id": str(read_json(task_dir / "task.json").get("task_id", task_dir.name)),
|
|
1119
|
+
"status": "proposed" if issues else "verified",
|
|
1120
|
+
"source_reports": reports,
|
|
1121
|
+
"issues": issues,
|
|
1122
|
+
"generated_at": now(),
|
|
1123
|
+
"next_agent": {"action": "Resolve open findings in priority order, then rerun the declared scope.", "skill": "motionloom", "evidence_needed": ["semantic-lint-report.json", "continuity-report.json", "quality-report.json"]},
|
|
1124
|
+
}
|
|
1125
|
+
output = Path(args.output).expanduser().resolve() if args.output else task_dir / "fix-plan.json"
|
|
1126
|
+
if not within(task_dir, output):
|
|
1127
|
+
raise ValueError("fix-plan output must be inside the task bundle")
|
|
1128
|
+
write_json(output, plan)
|
|
1129
|
+
sync_fix_plan_handoff(task_dir, plan, output.relative_to(task_dir).as_posix())
|
|
1130
|
+
print(json.dumps({"status": "built", "kind": "fix-plan", "task_id": plan["task_id"], "issue_count": len(issues), "plan_status": plan["status"], "path": str(output)}, ensure_ascii=False))
|
|
1131
|
+
return 0
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def sync_fix_plan_handoff(task_dir: Path, plan: dict[str, Any], plan_path: str) -> None:
|
|
1135
|
+
"""Project P1 findings into the existing report/issue/handoff contracts."""
|
|
1136
|
+
issue_path = task_dir / "issue-register.json"
|
|
1137
|
+
issue_register = read_json(issue_path) if issue_path.is_file() else {"version": "1.0", "task_id": plan["task_id"], "issues": []}
|
|
1138
|
+
existing = {str(item.get("id")): item for item in issue_register.get("issues", []) if item.get("id")}
|
|
1139
|
+
plan_issue_ids = set()
|
|
1140
|
+
for issue in plan.get("issues", []):
|
|
1141
|
+
issue_id = f"{plan['plan_id']}:{issue['id']}"
|
|
1142
|
+
plan_issue_ids.add(issue_id)
|
|
1143
|
+
existing[issue_id] = {
|
|
1144
|
+
"id": issue_id,
|
|
1145
|
+
"summary": issue.get("root_cause", "P1 finding requires investigation."),
|
|
1146
|
+
"status": issue.get("status", "open"),
|
|
1147
|
+
"severity": issue.get("severity", "warning"),
|
|
1148
|
+
"confidence": issue.get("confidence", 0.5),
|
|
1149
|
+
"evidence": [issue.get("finding_ref", ""), plan_path],
|
|
1150
|
+
"next_action": "; ".join(issue.get("patch_scope", [])),
|
|
1151
|
+
"fix_plan": plan_path,
|
|
1152
|
+
"rerun_scope": issue.get("rerun_scope", []),
|
|
1153
|
+
"requires_user_review": bool(issue.get("requires_user_review")),
|
|
1154
|
+
}
|
|
1155
|
+
issue_register["task_id"] = plan["task_id"]
|
|
1156
|
+
issue_register["issues"] = list(existing.values())
|
|
1157
|
+
write_json(issue_path, issue_register)
|
|
1158
|
+
|
|
1159
|
+
report_path = task_dir / "execution-report.json"
|
|
1160
|
+
report = read_json(report_path)
|
|
1161
|
+
report_issues = {str(item.get("id")): item for item in report.get("problems", []) if item.get("id")}
|
|
1162
|
+
for issue_id in plan_issue_ids:
|
|
1163
|
+
report_issues[issue_id] = existing[issue_id]
|
|
1164
|
+
report["problems"] = list(report_issues.values())
|
|
1165
|
+
next_agent = [item for item in report.get("next_agent", []) if item.get("id") != plan["plan_id"]]
|
|
1166
|
+
next_agent.append({
|
|
1167
|
+
"id": plan["plan_id"],
|
|
1168
|
+
"agent": "motionloom",
|
|
1169
|
+
"skill": "motionloom",
|
|
1170
|
+
"action": plan["next_agent"]["action"],
|
|
1171
|
+
"status": "pending" if plan.get("issues") else "complete",
|
|
1172
|
+
"evidence_needed": [plan_path] + plan["next_agent"].get("evidence_needed", []),
|
|
1173
|
+
})
|
|
1174
|
+
report["next_agent"] = next_agent
|
|
1175
|
+
report["p1_feedback"] = {"fix_plan": plan_path, "issue_count": len(plan.get("issues", [])), "status": plan.get("status"), "generated_at": plan.get("generated_at")}
|
|
1176
|
+
report["generated_at"] = now()
|
|
1177
|
+
write_json(report_path, report)
|
|
1178
|
+
|
|
1179
|
+
handoff_path = task_dir / "handoff.json"
|
|
1180
|
+
handoff = read_json(handoff_path)
|
|
1181
|
+
required = list(handoff.get("required_artifacts", []))
|
|
1182
|
+
for artifact in (plan_path, "semantic-lint-report.json", "continuity-report.json", "issue-register.json"):
|
|
1183
|
+
if artifact not in required:
|
|
1184
|
+
required.append(artifact)
|
|
1185
|
+
handoff["required_artifacts"] = required
|
|
1186
|
+
handoff["fix_plan"] = {"path": plan_path, "status": plan.get("status"), "issue_count": len(plan.get("issues", [])), "requires_user_review": any(item.get("requires_user_review") for item in plan.get("issues", []))}
|
|
1187
|
+
actions = [item for item in handoff.get("next_actions", []) if item.get("id") != plan["plan_id"]]
|
|
1188
|
+
actions.append({
|
|
1189
|
+
"id": plan["plan_id"],
|
|
1190
|
+
"action": plan["next_agent"]["action"],
|
|
1191
|
+
"kind": "p1_fix_plan",
|
|
1192
|
+
"agent": "motionloom",
|
|
1193
|
+
"skill": "motionloom",
|
|
1194
|
+
"fix_plan": plan_path,
|
|
1195
|
+
"requires_user_approval": any(item.get("requires_user_review") for item in plan.get("issues", [])),
|
|
1196
|
+
"evidence_needed": [plan_path] + plan["next_agent"].get("evidence_needed", []),
|
|
1197
|
+
})
|
|
1198
|
+
handoff["next_actions"] = actions
|
|
1199
|
+
blockers = [item for item in handoff.get("blockers", []) if not str(item).startswith(f"{plan['plan_id']}:")]
|
|
1200
|
+
blockers.extend(issue_id for issue_id in sorted(plan_issue_ids) if existing[issue_id].get("requires_user_review"))
|
|
1201
|
+
handoff["blockers"] = blockers
|
|
1202
|
+
handoff["feedback"] = {"source": "semantic-lint/continuity", "status": plan.get("status"), "issue_count": len(plan.get("issues", [])), "fix_plan": plan_path}
|
|
1203
|
+
write_json(handoff_path, handoff)
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def fix_plan_validate(args: argparse.Namespace) -> int:
|
|
1207
|
+
path = Path(args.path).expanduser().resolve()
|
|
1208
|
+
plan = read_json(path)
|
|
1209
|
+
issues = []
|
|
1210
|
+
if plan.get("schema_version") != "0.1":
|
|
1211
|
+
issues.append("schema_version must be 0.1")
|
|
1212
|
+
if plan.get("status") not in {"proposed", "accepted", "in_progress", "verified", "blocked"}:
|
|
1213
|
+
issues.append("invalid fix-plan status")
|
|
1214
|
+
if not isinstance(plan.get("source_reports"), list) or not plan.get("source_reports"):
|
|
1215
|
+
issues.append("source_reports must not be empty")
|
|
1216
|
+
if not isinstance(plan.get("issues"), list):
|
|
1217
|
+
issues.append("issues must be an array")
|
|
1218
|
+
if issues:
|
|
1219
|
+
raise ValueError("fix plan invalid: " + "; ".join(issues))
|
|
1220
|
+
print(json.dumps({"status": "valid", "kind": "fix-plan", "task_id": plan.get("task_id"), "issue_count": len(plan.get("issues", [])), "plan_status": plan.get("status")}, ensure_ascii=False))
|
|
1221
|
+
return 0
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def validate_task_p1(task_dir: Path, scene: str | None = None) -> list[str]:
|
|
1225
|
+
"""Validate the P1 reports and their handoff binding without side effects."""
|
|
1226
|
+
issues: list[str] = []
|
|
1227
|
+
required = ("semantic-lint-report.json", "continuity-report.json", "fix-plan.json")
|
|
1228
|
+
if any((task_dir / name).is_file() for name in required):
|
|
1229
|
+
for name in required:
|
|
1230
|
+
if not (task_dir / name).is_file():
|
|
1231
|
+
issues.append(f"missing {name}")
|
|
1232
|
+
if issues:
|
|
1233
|
+
return issues
|
|
1234
|
+
try:
|
|
1235
|
+
task = read_json(task_dir / "task.json")
|
|
1236
|
+
lint = read_json(task_dir / "semantic-lint-report.json")
|
|
1237
|
+
continuity = read_json(task_dir / "continuity-report.json")
|
|
1238
|
+
plan = read_json(task_dir / "fix-plan.json")
|
|
1239
|
+
issues.extend(f"semantic lint: {item}" for item in semantic_lint_validate_data(lint))
|
|
1240
|
+
issues.extend(f"continuity: {item}" for item in continuity_validate_data(continuity))
|
|
1241
|
+
if plan.get("schema_version") != "0.1":
|
|
1242
|
+
issues.append("fix plan: schema_version must be 0.1")
|
|
1243
|
+
if plan.get("task_id") != task.get("task_id"):
|
|
1244
|
+
issues.append("fix plan task_id does not match task.json")
|
|
1245
|
+
if scene and lint.get("scene") != scene:
|
|
1246
|
+
issues.append("semantic lint scene does not match quality-gate scene")
|
|
1247
|
+
for source in plan.get("source_reports", []):
|
|
1248
|
+
raw_path = str(source.get("path", ""))
|
|
1249
|
+
try:
|
|
1250
|
+
source_path = (task_dir / safe_relative(raw_path)).resolve()
|
|
1251
|
+
except ValueError as exc:
|
|
1252
|
+
issues.append(f"fix plan source path invalid: {exc}")
|
|
1253
|
+
continue
|
|
1254
|
+
if not within(task_dir, source_path) or not source_path.is_file():
|
|
1255
|
+
issues.append(f"fix plan source report missing: {raw_path}")
|
|
1256
|
+
elif source.get("sha256") != digest_file(source_path):
|
|
1257
|
+
issues.append(f"fix plan source report hash mismatch: {raw_path}")
|
|
1258
|
+
handoff = read_json(task_dir / "handoff.json")
|
|
1259
|
+
if handoff.get("fix_plan", {}).get("path") != "fix-plan.json":
|
|
1260
|
+
issues.append("handoff fix_plan must point to fix-plan.json")
|
|
1261
|
+
if "semantic-lint-report.json" not in handoff.get("required_artifacts", []):
|
|
1262
|
+
issues.append("handoff required_artifacts must include semantic-lint-report.json")
|
|
1263
|
+
if "continuity-report.json" not in handoff.get("required_artifacts", []):
|
|
1264
|
+
issues.append("handoff required_artifacts must include continuity-report.json")
|
|
1265
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
1266
|
+
issues.append(f"P1 validation error: {exc}")
|
|
1267
|
+
return issues
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def node_version() -> str:
|
|
1271
|
+
try:
|
|
1272
|
+
return subprocess.run(["node", "--version"], capture_output=True, text=True, check=False).stdout.strip() or "unavailable"
|
|
1273
|
+
except OSError:
|
|
1274
|
+
return "unavailable"
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def replay_capture(args: argparse.Namespace) -> int:
|
|
1278
|
+
root = Path(args.root).expanduser().resolve()
|
|
1279
|
+
task_dir = task_dir_from(args.task_dir)
|
|
1280
|
+
if not within(root, task_dir):
|
|
1281
|
+
raise ValueError("task directory must be inside replay root")
|
|
1282
|
+
task = read_json(task_dir / "task.json")
|
|
1283
|
+
task_rel = task_dir.relative_to(root).as_posix()
|
|
1284
|
+
records = []
|
|
1285
|
+
for path in sorted(task_dir.rglob("*")):
|
|
1286
|
+
if has_symlink_component(path):
|
|
1287
|
+
raise ValueError(f"task bundle cannot contain symlinked artifact: {path.relative_to(task_dir)}")
|
|
1288
|
+
if not path.is_file():
|
|
1289
|
+
continue
|
|
1290
|
+
relative = path.relative_to(root).as_posix()
|
|
1291
|
+
if path.name in GENERATED_REPLAY or path.name in GENERATED_REPORTS:
|
|
1292
|
+
continue
|
|
1293
|
+
records.append({"path": relative, "sha256": digest_file(path), "bytes": path.stat().st_size})
|
|
1294
|
+
bundle = {
|
|
1295
|
+
"schema_version": "0.1",
|
|
1296
|
+
"bundle_id": f"replay-{task.get('task_id', task_dir.name)}-{task.get('scene', 'scene')}",
|
|
1297
|
+
"task_id": task.get("task_id"),
|
|
1298
|
+
"scene": task.get("scene"),
|
|
1299
|
+
"task_dir": task_rel,
|
|
1300
|
+
"generated_at": now(),
|
|
1301
|
+
"environment": {"python": platform.python_version(), "node": node_version(), "os": platform.platform()},
|
|
1302
|
+
"files": records,
|
|
1303
|
+
"policy": {"hash_algorithm": "sha256", "exclude": sorted(GENERATED_REPLAY | GENERATED_REPORTS), "mode": "integrity"},
|
|
1304
|
+
"replay_command": "python3 scripts/intelligence.py replay verify --bundle <bundle> --root <clean-root>",
|
|
1305
|
+
}
|
|
1306
|
+
output = Path(args.output).expanduser().resolve() if args.output else task_dir / "replay-bundle.json"
|
|
1307
|
+
write_json(output, bundle)
|
|
1308
|
+
print(json.dumps({"status": "captured", "kind": "replay-bundle", "task_id": bundle["task_id"], "file_count": len(records), "path": str(output)}, ensure_ascii=False))
|
|
1309
|
+
return 0
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def replay_verify(args: argparse.Namespace) -> int:
|
|
1313
|
+
bundle = read_json(Path(args.bundle).expanduser().resolve())
|
|
1314
|
+
root = Path(args.root).expanduser().resolve()
|
|
1315
|
+
mismatches = []
|
|
1316
|
+
try:
|
|
1317
|
+
task_rel = safe_relative(str(bundle.get("task_dir", "")))
|
|
1318
|
+
task_dir = (root / task_rel).resolve()
|
|
1319
|
+
task = read_json(task_dir / "task.json")
|
|
1320
|
+
if has_symlink_component(root / task_rel) or not within(root, task_dir) or not task_dir.is_dir():
|
|
1321
|
+
mismatches.append({"path": str(task_rel), "reason": "task_dir_invalid"})
|
|
1322
|
+
else:
|
|
1323
|
+
if bundle.get("task_id") != task.get("task_id"):
|
|
1324
|
+
mismatches.append({"path": str(task_rel), "reason": "task_id_mismatch"})
|
|
1325
|
+
if bundle.get("scene") != task.get("scene"):
|
|
1326
|
+
mismatches.append({"path": str(task_rel), "reason": "scene_mismatch"})
|
|
1327
|
+
except (ValueError, OSError, TypeError):
|
|
1328
|
+
mismatches.append({"path": str(bundle.get("task_dir")), "reason": "task_dir_invalid"})
|
|
1329
|
+
for record in bundle.get("files", []):
|
|
1330
|
+
safe = safe_relative(str(record.get("path", "")))
|
|
1331
|
+
raw_path = root / safe
|
|
1332
|
+
path = raw_path.resolve()
|
|
1333
|
+
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():
|
|
1334
|
+
mismatches.append({"path": str(safe), "reason": "missing"})
|
|
1335
|
+
continue
|
|
1336
|
+
actual = digest_file(path)
|
|
1337
|
+
if actual != record.get("sha256"):
|
|
1338
|
+
mismatches.append({"path": str(safe), "reason": "hash_mismatch", "expected": record.get("sha256"), "actual": actual})
|
|
1339
|
+
if mismatches:
|
|
1340
|
+
print(json.dumps({"status": "invalid", "kind": "replay-bundle", "mismatches": mismatches}, ensure_ascii=False))
|
|
1341
|
+
return 1
|
|
1342
|
+
print(json.dumps({"status": "valid", "kind": "replay-bundle", "task_id": bundle.get("task_id"), "file_count": len(bundle.get("files", []))}, ensure_ascii=False))
|
|
1343
|
+
return 0
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def replay_mismatches(bundle: dict[str, Any], root: Path) -> list[dict[str, Any]]:
|
|
1347
|
+
mismatches = []
|
|
1348
|
+
for record in bundle.get("files", []):
|
|
1349
|
+
safe = safe_relative(str(record.get("path", "")))
|
|
1350
|
+
raw_path = root / safe
|
|
1351
|
+
path = raw_path.resolve()
|
|
1352
|
+
if has_symlink_component(raw_path) or not within(root, path) or not path.is_file():
|
|
1353
|
+
mismatches.append({"path": str(safe), "reason": "missing"})
|
|
1354
|
+
continue
|
|
1355
|
+
actual = digest_file(path)
|
|
1356
|
+
if actual != record.get("sha256"):
|
|
1357
|
+
mismatches.append({"path": str(safe), "reason": "hash_mismatch", "expected": record.get("sha256"), "actual": actual})
|
|
1358
|
+
return mismatches
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def validate_task_intelligence(task_dir: Path, scene: str | None = None) -> list[str]:
|
|
1362
|
+
"""Return deterministic contract issues for a task bundle.
|
|
1363
|
+
|
|
1364
|
+
This function is intentionally side-effect free so quality gates and tests
|
|
1365
|
+
can use the same verification logic as the CLI.
|
|
1366
|
+
"""
|
|
1367
|
+
issues: list[str] = []
|
|
1368
|
+
try:
|
|
1369
|
+
task = read_json(task_dir / "task.json")
|
|
1370
|
+
if scene and task.get("scene") != scene:
|
|
1371
|
+
issues.append("intelligence task scene does not match quality-gate scene")
|
|
1372
|
+
graph_path = task_dir / "project-graph.json"
|
|
1373
|
+
if not graph_path.is_file():
|
|
1374
|
+
issues.append("missing project-graph.json")
|
|
1375
|
+
else:
|
|
1376
|
+
graph_validate(argparse.Namespace(path=str(graph_path)))
|
|
1377
|
+
graph = read_json(graph_path)
|
|
1378
|
+
if graph.get("task_id") != task.get("task_id"):
|
|
1379
|
+
issues.append("project graph task_id does not match task.json")
|
|
1380
|
+
motion_ir_path = task_dir / "motion-ir.json"
|
|
1381
|
+
if not motion_ir_path.is_file():
|
|
1382
|
+
issues.append("missing motion-ir.json")
|
|
1383
|
+
else:
|
|
1384
|
+
motion_ir_validate(argparse.Namespace(path=str(motion_ir_path)))
|
|
1385
|
+
motion_ir = read_json(motion_ir_path)
|
|
1386
|
+
if motion_ir.get("task_id") != task.get("task_id") or motion_ir.get("scene") != task.get("scene"):
|
|
1387
|
+
issues.append("Motion IR task_id/scene does not match task.json")
|
|
1388
|
+
provenance_path = task_dir / "provenance.json"
|
|
1389
|
+
if not provenance_path.is_file():
|
|
1390
|
+
issues.append("missing provenance.json")
|
|
1391
|
+
else:
|
|
1392
|
+
provenance_validate(argparse.Namespace(task_dir=str(task_dir), path=str(provenance_path)))
|
|
1393
|
+
replay_path = task_dir / "replay-bundle.json"
|
|
1394
|
+
if not replay_path.is_file():
|
|
1395
|
+
issues.append("missing replay-bundle.json")
|
|
1396
|
+
else:
|
|
1397
|
+
bundle = read_json(replay_path)
|
|
1398
|
+
replay_root = task_dir.parent.parent.resolve()
|
|
1399
|
+
if not within(replay_root, task_dir):
|
|
1400
|
+
issues.append("replay task bundle is outside replay root")
|
|
1401
|
+
expected_task_dir = task_dir.relative_to(replay_root).as_posix() if within(replay_root, task_dir) else ""
|
|
1402
|
+
if bundle.get("task_dir") != expected_task_dir:
|
|
1403
|
+
issues.append("replay bundle task_dir does not match task bundle")
|
|
1404
|
+
if bundle.get("scene") != task.get("scene"):
|
|
1405
|
+
issues.append("replay bundle scene does not match task.json")
|
|
1406
|
+
for record in bundle.get("files", []):
|
|
1407
|
+
try:
|
|
1408
|
+
record_path = (replay_root / safe_relative(str(record.get("path", "")))).resolve()
|
|
1409
|
+
if not within(task_dir, record_path):
|
|
1410
|
+
issues.append(f"replay bundle references artifact outside task bundle: {record.get('path')}")
|
|
1411
|
+
except ValueError as exc:
|
|
1412
|
+
issues.append(f"replay bundle path invalid: {exc}")
|
|
1413
|
+
mismatches = replay_mismatches(bundle, replay_root)
|
|
1414
|
+
if mismatches:
|
|
1415
|
+
issues.append(f"replay bundle has {len(mismatches)} mismatch(es)")
|
|
1416
|
+
if bundle.get("task_id") != task.get("task_id"):
|
|
1417
|
+
issues.append("replay bundle task_id does not match task.json")
|
|
1418
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
1419
|
+
issues.append(f"intelligence validation error: {exc}")
|
|
1420
|
+
return issues
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def add_subcommands(parser: argparse.ArgumentParser) -> None:
|
|
1424
|
+
sub = parser.add_subparsers(dest="domain", required=True)
|
|
1425
|
+
|
|
1426
|
+
graph = sub.add_parser("graph", help="build or validate project-graph.json")
|
|
1427
|
+
graph_sub = graph.add_subparsers(dest="action", required=True)
|
|
1428
|
+
graph_build_parser = graph_sub.add_parser("build")
|
|
1429
|
+
graph_build_parser.add_argument("--task-dir", required=True)
|
|
1430
|
+
graph_build_parser.add_argument("--output")
|
|
1431
|
+
graph_build_parser.set_defaults(func=graph_build)
|
|
1432
|
+
graph_validate_parser = graph_sub.add_parser("validate")
|
|
1433
|
+
graph_validate_parser.add_argument("--path", required=True)
|
|
1434
|
+
graph_validate_parser.set_defaults(func=graph_validate)
|
|
1435
|
+
|
|
1436
|
+
provenance = sub.add_parser("provenance", help="build or validate provenance.json")
|
|
1437
|
+
provenance_sub = provenance.add_subparsers(dest="action", required=True)
|
|
1438
|
+
provenance_build_parser = provenance_sub.add_parser("build")
|
|
1439
|
+
provenance_build_parser.add_argument("--task-dir", required=True)
|
|
1440
|
+
provenance_build_parser.add_argument("--steps-file")
|
|
1441
|
+
provenance_build_parser.add_argument("--subject")
|
|
1442
|
+
provenance_build_parser.add_argument("--output")
|
|
1443
|
+
provenance_build_parser.set_defaults(func=provenance_build)
|
|
1444
|
+
provenance_validate_parser = provenance_sub.add_parser("validate")
|
|
1445
|
+
provenance_validate_parser.add_argument("--task-dir", required=True)
|
|
1446
|
+
provenance_validate_parser.add_argument("--path", required=True)
|
|
1447
|
+
provenance_validate_parser.set_defaults(func=provenance_validate)
|
|
1448
|
+
|
|
1449
|
+
capabilities = sub.add_parser("capabilities", help="build, validate or select capability registry")
|
|
1450
|
+
capabilities_sub = capabilities.add_subparsers(dest="action", required=True)
|
|
1451
|
+
capabilities_build_parser = capabilities_sub.add_parser("build")
|
|
1452
|
+
capabilities_build_parser.add_argument("--card", default=str(ROOT / "agent-card.json"))
|
|
1453
|
+
capabilities_build_parser.add_argument("--evidence")
|
|
1454
|
+
capabilities_build_parser.add_argument("--evidence-kind", choices=["runtime", "ci", "browser", "static"], default="ci")
|
|
1455
|
+
capabilities_build_parser.add_argument("--output")
|
|
1456
|
+
capabilities_build_parser.set_defaults(func=capability_build)
|
|
1457
|
+
capabilities_validate_parser = capabilities_sub.add_parser("validate")
|
|
1458
|
+
capabilities_validate_parser.add_argument("--path", required=True)
|
|
1459
|
+
capabilities_validate_parser.set_defaults(func=capability_validate)
|
|
1460
|
+
capabilities_select_parser = capabilities_sub.add_parser("select")
|
|
1461
|
+
capabilities_select_parser.add_argument("--registry", required=True)
|
|
1462
|
+
capabilities_select_parser.add_argument("--capability")
|
|
1463
|
+
capabilities_select_parser.add_argument("--status", default="verified")
|
|
1464
|
+
capabilities_select_parser.add_argument("--allow-scaffold-only", action="store_true")
|
|
1465
|
+
capabilities_select_parser.set_defaults(func=capability_select)
|
|
1466
|
+
|
|
1467
|
+
replay = sub.add_parser("replay", help="capture or verify clean-root integrity replay bundle")
|
|
1468
|
+
replay_sub = replay.add_subparsers(dest="action", required=True)
|
|
1469
|
+
replay_capture_parser = replay_sub.add_parser("capture")
|
|
1470
|
+
replay_capture_parser.add_argument("--task-dir", required=True)
|
|
1471
|
+
replay_capture_parser.add_argument("--root", default=str(ROOT))
|
|
1472
|
+
replay_capture_parser.add_argument("--output")
|
|
1473
|
+
replay_capture_parser.set_defaults(func=replay_capture)
|
|
1474
|
+
replay_verify_parser = replay_sub.add_parser("verify")
|
|
1475
|
+
replay_verify_parser.add_argument("--bundle", required=True)
|
|
1476
|
+
replay_verify_parser.add_argument("--root", default=str(ROOT))
|
|
1477
|
+
replay_verify_parser.set_defaults(func=replay_verify)
|
|
1478
|
+
|
|
1479
|
+
motion_ir = sub.add_parser("motion-ir", help="build or validate framework-neutral motion-ir.json")
|
|
1480
|
+
motion_ir_sub = motion_ir.add_subparsers(dest="action", required=True)
|
|
1481
|
+
motion_ir_build_parser = motion_ir_sub.add_parser("build")
|
|
1482
|
+
motion_ir_build_parser.add_argument("--task-dir", required=True)
|
|
1483
|
+
motion_ir_build_parser.add_argument("--spec")
|
|
1484
|
+
motion_ir_build_parser.add_argument("--output")
|
|
1485
|
+
motion_ir_build_parser.set_defaults(func=motion_ir_build)
|
|
1486
|
+
motion_ir_validate_parser = motion_ir_sub.add_parser("validate")
|
|
1487
|
+
motion_ir_validate_parser.add_argument("--path", required=True)
|
|
1488
|
+
motion_ir_validate_parser.set_defaults(func=motion_ir_validate)
|
|
1489
|
+
|
|
1490
|
+
semantic_lint = sub.add_parser("semantic-lint", help="build or validate semantic-lint-report.json")
|
|
1491
|
+
semantic_lint_sub = semantic_lint.add_subparsers(dest="action", required=True)
|
|
1492
|
+
semantic_lint_build_parser = semantic_lint_sub.add_parser("build")
|
|
1493
|
+
semantic_lint_build_parser.add_argument("--task-dir", required=True)
|
|
1494
|
+
semantic_lint_build_parser.add_argument("--spec")
|
|
1495
|
+
semantic_lint_build_parser.add_argument("--output")
|
|
1496
|
+
semantic_lint_build_parser.set_defaults(func=semantic_lint_build)
|
|
1497
|
+
semantic_lint_validate_parser = semantic_lint_sub.add_parser("validate")
|
|
1498
|
+
semantic_lint_validate_parser.add_argument("--path", required=True)
|
|
1499
|
+
semantic_lint_validate_parser.set_defaults(func=semantic_lint_validate)
|
|
1500
|
+
semantic_lint_benchmark_parser = semantic_lint_sub.add_parser("benchmark", help="benchmark semantic-lint-build execution")
|
|
1501
|
+
semantic_lint_benchmark_parser.add_argument("--task-dir", required=True)
|
|
1502
|
+
semantic_lint_benchmark_parser.add_argument("--spec")
|
|
1503
|
+
semantic_lint_benchmark_parser.add_argument("--output")
|
|
1504
|
+
semantic_lint_benchmark_parser.add_argument("--iterations", type=int, default=25)
|
|
1505
|
+
semantic_lint_benchmark_parser.add_argument("--threshold-ms", type=float, default=500.0)
|
|
1506
|
+
semantic_lint_benchmark_parser.set_defaults(func=semantic_lint_benchmark)
|
|
1507
|
+
|
|
1508
|
+
continuity = sub.add_parser("continuity", help="build or validate continuity-report.json")
|
|
1509
|
+
continuity_sub = continuity.add_subparsers(dest="action", required=True)
|
|
1510
|
+
continuity_build_parser = continuity_sub.add_parser("build")
|
|
1511
|
+
continuity_build_parser.add_argument("--task-dirs", nargs="+", required=True)
|
|
1512
|
+
continuity_build_parser.add_argument("--project-id")
|
|
1513
|
+
continuity_build_parser.add_argument("--output")
|
|
1514
|
+
continuity_build_parser.set_defaults(func=continuity_build)
|
|
1515
|
+
continuity_validate_parser = continuity_sub.add_parser("validate")
|
|
1516
|
+
continuity_validate_parser.add_argument("--path", required=True)
|
|
1517
|
+
continuity_validate_parser.set_defaults(func=continuity_validate)
|
|
1518
|
+
|
|
1519
|
+
fix_plan = sub.add_parser("fix-plan", help="build or validate fix-plan.json")
|
|
1520
|
+
fix_plan_sub = fix_plan.add_subparsers(dest="action", required=True)
|
|
1521
|
+
fix_plan_build_parser = fix_plan_sub.add_parser("build")
|
|
1522
|
+
fix_plan_build_parser.add_argument("--task-dir", required=True)
|
|
1523
|
+
fix_plan_build_parser.add_argument("--reports", nargs="+", required=True)
|
|
1524
|
+
fix_plan_build_parser.add_argument("--output")
|
|
1525
|
+
fix_plan_build_parser.set_defaults(func=fix_plan_build)
|
|
1526
|
+
fix_plan_validate_parser = fix_plan_sub.add_parser("validate")
|
|
1527
|
+
fix_plan_validate_parser.add_argument("--path", required=True)
|
|
1528
|
+
fix_plan_validate_parser.set_defaults(func=fix_plan_validate)
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1532
|
+
parser = argparse.ArgumentParser(prog="intelligence.py", description=__doc__)
|
|
1533
|
+
add_subcommands(parser)
|
|
1534
|
+
args = parser.parse_args(argv)
|
|
1535
|
+
try:
|
|
1536
|
+
return int(args.func(args))
|
|
1537
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
1538
|
+
print(f"intelligence-error: {exc}", file=sys.stderr)
|
|
1539
|
+
return 2
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
if __name__ == "__main__":
|
|
1543
|
+
raise SystemExit(main())
|