motionloom 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -0
  3. package/SKILL.md +122 -0
  4. package/agent-card.json +161 -0
  5. package/assets/library/ATTRIBUTION.md +6 -0
  6. package/assets/library/README.md +20 -0
  7. package/assets/library/avatar-base.svg +19 -0
  8. package/assets/library/error-alert.json +1 -0
  9. package/assets/library/rive/ATTRIBUTION.md +12 -0
  10. package/assets/library/rive/state-machine-test.riv +0 -0
  11. package/assets/library/success-check.json +1 -0
  12. package/bin/motionloom.mjs +81 -0
  13. package/docs/BROWSER-REVIEW-E2E.md +78 -0
  14. package/docs/CATEGORIES.md +16 -0
  15. package/docs/CHECKLIST.md +31 -0
  16. package/docs/DEEP-AUDIT-WORKING-NOTES.md +22 -0
  17. package/docs/FRAMEWORK-SELECTION.md +26 -0
  18. package/docs/PROJECT-MANIFEST.md +37 -0
  19. package/docs/ROADMAP-INTELLIGENCE.md +224 -0
  20. package/docs/audits/1.10.0-attestation-research-notes.md +19 -0
  21. package/docs/audits/1.8.0-trust-boundary-hardening.md +56 -0
  22. package/docs/audits/1.9.0-evidence-interoperability-threat-model.md +37 -0
  23. package/docs/audits/2.0.0-attestation-acceptance.md +30 -0
  24. package/docs/releases/1.5.0.md +25 -0
  25. package/docs/releases/1.6.0.md +27 -0
  26. package/docs/releases/1.7.0.md +23 -0
  27. package/docs/releases/1.8.0.md +23 -0
  28. package/docs/releases/1.9.0.md +21 -0
  29. package/docs/releases/2.0.0.md +21 -0
  30. package/docs/releases/npm-publish-from-workstation.md +88 -0
  31. package/docs/research/AGENT-PROTOCOL-FINDINGS.md +43 -0
  32. package/examples/report-demo/REPORT.md +50 -0
  33. package/examples/report-demo/artifact-manifest.json +25 -0
  34. package/examples/report-demo/decision-log.jsonl +0 -0
  35. package/examples/report-demo/execution-report.json +70 -0
  36. package/examples/report-demo/handoff.json +22 -0
  37. package/examples/report-demo/issue-register.json +5 -0
  38. package/examples/report-demo/task.json +13 -0
  39. package/package.json +95 -0
  40. package/project-context.example.json +26 -0
  41. package/references/browser-review-contract.md +32 -0
  42. package/references/dotlottie-source-notes.md +21 -0
  43. package/references/intelligence-core.md +98 -0
  44. package/references/reporting-contract.md +38 -0
  45. package/references/runtime-capability.md +12 -0
  46. package/references/signed-attestation.md +31 -0
  47. package/schemas/artifact-manifest.schema.json +22 -0
  48. package/schemas/browser-review-candidate.schema.json +24 -0
  49. package/schemas/capability-registry.schema.json +61 -0
  50. package/schemas/continuity-report.schema.json +52 -0
  51. package/schemas/evidence-verifier-report.schema.json +35 -0
  52. package/schemas/execution-report.schema.json +35 -0
  53. package/schemas/fix-plan.schema.json +44 -0
  54. package/schemas/handoff.schema.json +19 -0
  55. package/schemas/motion-ir.schema.json +74 -0
  56. package/schemas/project-graph.schema.json +70 -0
  57. package/schemas/provenance.schema.json +76 -0
  58. package/schemas/runtime-evidence.schema.json +48 -0
  59. package/schemas/runtime-telemetry.schema.json +50 -0
  60. package/schemas/scene-manifest.schema.json +45 -0
  61. package/schemas/semantic-benchmark.schema.json +26 -0
  62. package/schemas/semantic-lint-report.schema.json +48 -0
  63. package/schemas/signed-attestation.schema.json +95 -0
  64. package/schemas/task.schema.json +39 -0
  65. package/schemas/trust-policy.schema.json +53 -0
  66. package/scripts/analyze.sh +13 -0
  67. package/scripts/attestation-keygen.py +63 -0
  68. package/scripts/attestation-verifier.py +178 -0
  69. package/scripts/attestation.py +288 -0
  70. package/scripts/capture-runtime-telemetry.sh +37 -0
  71. package/scripts/devlab.sh +77 -0
  72. package/scripts/eval-intelligence.py +377 -0
  73. package/scripts/evidence-verifier.py +222 -0
  74. package/scripts/fetch-library.sh +57 -0
  75. package/scripts/intelligence.py +1543 -0
  76. package/scripts/manifest.py +61 -0
  77. package/scripts/pr.sh +103 -0
  78. package/scripts/quality-gate.py +378 -0
  79. package/scripts/render-node.mjs +53 -0
  80. package/scripts/render.sh +37 -0
  81. package/scripts/report-contract.py +181 -0
  82. package/scripts/report.py +588 -0
  83. package/scripts/review-hook.py +199 -0
  84. package/scripts/runtime-adapters.mjs +187 -0
  85. package/scripts/skill-doctor.py +150 -0
  86. package/scripts/to-dotlottie.mjs +99 -0
  87. package/scripts/to-dotlottie.sh +25 -0
  88. package/scripts/validate-lottie.py +102 -0
  89. package/src/core/analyzer.py +226 -0
  90. package/src/core/snapshot.py +124 -0
  91. package/src/core/spec.py +240 -0
  92. package/src/output/browser-review-smoke/animation.json +57 -0
  93. package/src/output/browser-review-smoke/browser-review.json +21 -0
  94. package/src/output/browser-review-smoke/manifest.json +22 -0
  95. package/src/output/browser-review-smoke/motion-spec.json +28 -0
  96. package/src/output/browser-review-smoke/snapshot/.render-meta.json +10 -0
  97. package/src/output/browser-review-smoke/snapshot/frame-00.png +0 -0
  98. package/src/output/browser-review-smoke/snapshot/frame-100.png +0 -0
  99. package/src/output/browser-review-smoke/snapshot/frame-50.png +0 -0
  100. package/src/rig/README.md +35 -0
  101. package/src/rig/cutout_rig.py +211 -0
  102. package/templates/framer-motion/ui-micro.tsx +50 -0
  103. package/templates/gsap/scroll-scene.js +54 -0
  104. package/templates/lottie/README.md +21 -0
  105. package/templates/lottie/react-component.tsx +82 -0
  106. package/templates/lottie/scaffold/animation.json +57 -0
  107. package/templates/lottie/scaffold/character-rig.svg +19 -0
  108. package/templates/lottie/vanilla.js +68 -0
  109. package/templates/rive/README.md +36 -0
  110. package/tests/evals/intelligence-cases.json +131 -0
  111. package/tests/scripts/run_tests.py +843 -0
  112. package/tests/scripts/test_attestation.py +172 -0
@@ -0,0 +1,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())