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,588 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create, collect and render machine-readable animation task reports."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
15
|
+
EVIDENCE_ARTIFACTS = (
|
|
16
|
+
"semantic-lint-benchmark.json",
|
|
17
|
+
"evidence-verifier-report.json",
|
|
18
|
+
"runtime-adapters/runtime-evidence.json",
|
|
19
|
+
)
|
|
20
|
+
STATES = ["created", "needs_context", "planning", "sourcing", "generating", "rendering", "review_required", "blocked", "failed", "validated", "ready_for_pr", "confirmed"]
|
|
21
|
+
TRANSITIONS = {
|
|
22
|
+
"created": {"needs_context", "planning", "blocked", "failed"},
|
|
23
|
+
"needs_context": {"planning", "blocked", "failed"},
|
|
24
|
+
"planning": {"sourcing", "generating", "blocked", "failed"},
|
|
25
|
+
"sourcing": {"generating", "blocked", "failed"},
|
|
26
|
+
"generating": {"rendering", "blocked", "failed"},
|
|
27
|
+
"rendering": {"review_required", "blocked", "failed"},
|
|
28
|
+
"review_required": {"validated", "blocked", "failed"},
|
|
29
|
+
"validated": {"ready_for_pr", "failed"},
|
|
30
|
+
"ready_for_pr": {"confirmed", "failed"},
|
|
31
|
+
"blocked": {"needs_context", "planning", "sourcing", "generating", "rendering", "failed"},
|
|
32
|
+
"failed": {"planning", "sourcing", "generating", "rendering", "blocked"},
|
|
33
|
+
"confirmed": set(),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def now() -> str:
|
|
38
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def write_json(path: Path, value: dict) -> None:
|
|
42
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_json(path: Path, default: dict | list | None = None):
|
|
47
|
+
if not path.is_file():
|
|
48
|
+
return {} if default is None else default
|
|
49
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def has_symlink_component(path: Path) -> bool:
|
|
53
|
+
current = path
|
|
54
|
+
while True:
|
|
55
|
+
if current.is_symlink():
|
|
56
|
+
return True
|
|
57
|
+
if current.parent == current:
|
|
58
|
+
return False
|
|
59
|
+
current = current.parent
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def candidate_expiry(candidate: dict) -> datetime | None:
|
|
63
|
+
value = candidate.get("expires_at")
|
|
64
|
+
if not value:
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
68
|
+
return parsed.astimezone(timezone.utc) if parsed.tzinfo else None
|
|
69
|
+
except (TypeError, ValueError):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def candidate_is_current(candidate: dict) -> bool:
|
|
74
|
+
expiry = candidate_expiry(candidate)
|
|
75
|
+
return expiry is not None and datetime.now(timezone.utc) <= expiry
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def init_task(args: argparse.Namespace) -> int:
|
|
79
|
+
task_dir = Path(args.output or ROOT / "artifacts" / args.task_id).resolve()
|
|
80
|
+
timestamp = now()
|
|
81
|
+
task = {
|
|
82
|
+
"schema_version": "1.0",
|
|
83
|
+
"task_id": args.task_id,
|
|
84
|
+
"scene": args.scene,
|
|
85
|
+
"intent": args.intent,
|
|
86
|
+
"project_name": args.project_name,
|
|
87
|
+
"context_path": args.context_path,
|
|
88
|
+
"context_hash": args.context_hash,
|
|
89
|
+
"state": "created",
|
|
90
|
+
"browser_review": {"required": True, "status": "not_prepared", "review_artifact": "review.json"},
|
|
91
|
+
"owner_agent": args.agent,
|
|
92
|
+
"created_at": timestamp,
|
|
93
|
+
"updated_at": timestamp,
|
|
94
|
+
}
|
|
95
|
+
report = {
|
|
96
|
+
"report_version": "1.0",
|
|
97
|
+
"task_id": args.task_id,
|
|
98
|
+
"status": "created",
|
|
99
|
+
"confidence": "low",
|
|
100
|
+
"completed": [],
|
|
101
|
+
"verified": [],
|
|
102
|
+
"not_completed": [{"id": "initial", "summary": "Task has not run yet.", "status": "pending"}],
|
|
103
|
+
"problems": [],
|
|
104
|
+
"structure_review": {"missing_files": [], "broken_references": [], "untracked_artifacts": []},
|
|
105
|
+
"browser_review": [],
|
|
106
|
+
"next_agent": [{"agent": args.agent, "action": "Run project analysis and populate context before generation."}],
|
|
107
|
+
"generated_at": timestamp,
|
|
108
|
+
}
|
|
109
|
+
handoff = {
|
|
110
|
+
"handoff_version": "1.0",
|
|
111
|
+
"task_id": args.task_id,
|
|
112
|
+
"from_agent": args.agent,
|
|
113
|
+
"to_agent": "motionloom",
|
|
114
|
+
"state": "created",
|
|
115
|
+
"summary": "New animation task initialized.",
|
|
116
|
+
"next_actions": [{"action": "Analyze host project context", "skill": "motionloom", "evidence_needed": ["project-context.json"]}],
|
|
117
|
+
"required_artifacts": ["task.json", "execution-report.json", *EVIDENCE_ARTIFACTS],
|
|
118
|
+
"blockers": [],
|
|
119
|
+
}
|
|
120
|
+
write_json(task_dir / "task.json", task)
|
|
121
|
+
write_json(task_dir / "execution-report.json", report)
|
|
122
|
+
write_json(task_dir / "issue-register.json", {"version": "1.0", "task_id": args.task_id, "issues": []})
|
|
123
|
+
write_json(task_dir / "handoff.json", handoff)
|
|
124
|
+
write_json(task_dir / "artifact-manifest.json", {"manifest_version": "1.0", "task_id": args.task_id, "generated_at": timestamp, "artifacts": []})
|
|
125
|
+
(task_dir / "decision-log.jsonl").write_text("", encoding="utf-8")
|
|
126
|
+
print(json.dumps({"status": "created", "task_id": args.task_id, "task_dir": str(task_dir)}, ensure_ascii=False))
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def collect(args: argparse.Namespace) -> int:
|
|
131
|
+
task_dir = Path(args.task_dir).resolve()
|
|
132
|
+
task = read_json(task_dir / "task.json")
|
|
133
|
+
excluded = {"artifact-manifest.json", "execution-report.json", "decision-log.jsonl"}
|
|
134
|
+
artifacts = []
|
|
135
|
+
for path in sorted(task_dir.rglob("*")):
|
|
136
|
+
if has_symlink_component(path):
|
|
137
|
+
raise ValueError(f"task bundle cannot contain symlinked artifact: {path.relative_to(task_dir)}")
|
|
138
|
+
if not path.is_file() or path.name in excluded:
|
|
139
|
+
continue
|
|
140
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
141
|
+
artifacts.append({"path": str(path.relative_to(task_dir)), "type": path.suffix.lstrip(".") or "file", "sha256": digest, "bytes": path.stat().st_size})
|
|
142
|
+
manifest = {"manifest_version": "1.0", "task_id": task.get("task_id", task_dir.name), "generated_at": now(), "artifacts": artifacts}
|
|
143
|
+
write_json(task_dir / "artifact-manifest.json", manifest)
|
|
144
|
+
handoff_path = task_dir / "handoff.json"
|
|
145
|
+
if handoff_path.is_file():
|
|
146
|
+
handoff = read_json(handoff_path)
|
|
147
|
+
required = set(handoff.get("required_artifacts", []))
|
|
148
|
+
required.update(name for name in EVIDENCE_ARTIFACTS if (task_dir / name).is_file())
|
|
149
|
+
handoff["required_artifacts"] = sorted(required)
|
|
150
|
+
write_json(handoff_path, handoff)
|
|
151
|
+
print(json.dumps({"status": "collected", "task_id": manifest["task_id"], "artifact_count": len(artifacts)}, ensure_ascii=False))
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def add_item(args: argparse.Namespace) -> int:
|
|
156
|
+
task_dir = Path(args.task_dir).resolve()
|
|
157
|
+
report_path = task_dir / "execution-report.json"
|
|
158
|
+
report = read_json(report_path)
|
|
159
|
+
section = args.section
|
|
160
|
+
if section not in {"completed", "verified", "not_completed", "problems", "next_agent"}:
|
|
161
|
+
print(f"Unsupported report section: {section}", file=sys.stderr)
|
|
162
|
+
return 2
|
|
163
|
+
item = {
|
|
164
|
+
"id": args.id,
|
|
165
|
+
"summary": args.summary,
|
|
166
|
+
"status": args.status,
|
|
167
|
+
}
|
|
168
|
+
if args.evidence:
|
|
169
|
+
item["evidence"] = args.evidence
|
|
170
|
+
if args.confidence:
|
|
171
|
+
item["confidence"] = args.confidence
|
|
172
|
+
if args.severity:
|
|
173
|
+
item["severity"] = args.severity
|
|
174
|
+
if args.next_action:
|
|
175
|
+
item["next_action"] = args.next_action
|
|
176
|
+
if args.agent:
|
|
177
|
+
item["agent"] = args.agent
|
|
178
|
+
if args.skill:
|
|
179
|
+
item["skill"] = args.skill
|
|
180
|
+
if args.evidence_needed:
|
|
181
|
+
item["evidence_needed"] = args.evidence_needed
|
|
182
|
+
if section in {"completed", "verified"}:
|
|
183
|
+
report["not_completed"] = [
|
|
184
|
+
entry for entry in report.get("not_completed", []) if entry.get("id") != "initial"
|
|
185
|
+
]
|
|
186
|
+
report.setdefault(section, []).append(item)
|
|
187
|
+
report["generated_at"] = now()
|
|
188
|
+
write_json(report_path, report)
|
|
189
|
+
print(json.dumps({"status": "recorded", "section": section, "id": args.id, "task_dir": str(task_dir)}, ensure_ascii=False))
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def record_review(args: argparse.Namespace) -> int:
|
|
194
|
+
task_dir = Path(args.task_dir).resolve()
|
|
195
|
+
task = read_json(task_dir / "task.json")
|
|
196
|
+
candidate_path = task_dir / "browser-review.json"
|
|
197
|
+
candidate = read_json(candidate_path)
|
|
198
|
+
if not candidate:
|
|
199
|
+
print("browser-review.json is required before recording a review", file=sys.stderr)
|
|
200
|
+
return 2
|
|
201
|
+
if args.candidate_id and candidate.get("candidate_id") != args.candidate_id:
|
|
202
|
+
print("review candidate id does not match browser-review.json", file=sys.stderr)
|
|
203
|
+
return 2
|
|
204
|
+
if candidate.get("task_id") != task.get("task_id"):
|
|
205
|
+
print("review candidate task_id does not match task.json", file=sys.stderr)
|
|
206
|
+
return 2
|
|
207
|
+
if candidate.get("scene") != task.get("scene"):
|
|
208
|
+
print("review candidate scene does not match task.json", file=sys.stderr)
|
|
209
|
+
return 2
|
|
210
|
+
if not str(args.reviewer or "").strip():
|
|
211
|
+
print("reviewer is required and cannot be empty", file=sys.stderr)
|
|
212
|
+
return 2
|
|
213
|
+
expires_at = candidate.get("expires_at")
|
|
214
|
+
if not expires_at:
|
|
215
|
+
print("browser-review candidate expiry is required", file=sys.stderr)
|
|
216
|
+
return 2
|
|
217
|
+
if candidate_expiry(candidate) is None:
|
|
218
|
+
print("browser-review candidate expiry is invalid", file=sys.stderr)
|
|
219
|
+
return 2
|
|
220
|
+
if not candidate_is_current(candidate):
|
|
221
|
+
print("browser-review candidate has expired", file=sys.stderr)
|
|
222
|
+
return 2
|
|
223
|
+
if candidate.get("status") in {"expired", "approved"}:
|
|
224
|
+
print("browser-review candidate cannot be reviewed again; prepare a new candidate", file=sys.stderr)
|
|
225
|
+
return 2
|
|
226
|
+
scene_candidate_path = ROOT / "src" / "output" / str(task.get("scene", "")) / "browser-review.json"
|
|
227
|
+
scene_candidate = read_json(scene_candidate_path)
|
|
228
|
+
if scene_candidate_path.is_file() and scene_candidate.get("candidate_id") != candidate.get("candidate_id"):
|
|
229
|
+
print("task candidate does not match scene-level browser-review.json", file=sys.stderr)
|
|
230
|
+
return 2
|
|
231
|
+
review = {
|
|
232
|
+
"review_version": "1.0",
|
|
233
|
+
"task_id": task.get("task_id", task_dir.name),
|
|
234
|
+
"candidate_id": candidate.get("candidate_id"),
|
|
235
|
+
"decision": args.decision,
|
|
236
|
+
"reviewer": args.reviewer,
|
|
237
|
+
"notes": args.notes,
|
|
238
|
+
"feedback": args.feedback,
|
|
239
|
+
"reviewed_at": now(),
|
|
240
|
+
}
|
|
241
|
+
write_json(task_dir / "review.json", review)
|
|
242
|
+
candidate["status"] = {"pending": "reviewed", "approved": "approved", "changes_requested": "changes_requested", "rejected": "changes_requested"}[args.decision]
|
|
243
|
+
candidate["review_artifact"] = "review.json"
|
|
244
|
+
candidate["reviewed_at"] = review["reviewed_at"]
|
|
245
|
+
write_json(candidate_path, candidate)
|
|
246
|
+
if scene_candidate_path.is_file():
|
|
247
|
+
scene_candidate["status"] = candidate["status"]
|
|
248
|
+
scene_candidate["review_artifact"] = "review.json"
|
|
249
|
+
scene_candidate["reviewed_at"] = review["reviewed_at"]
|
|
250
|
+
write_json(scene_candidate_path, scene_candidate)
|
|
251
|
+
task["browser_review"] = {
|
|
252
|
+
"required": True,
|
|
253
|
+
"status": candidate["status"],
|
|
254
|
+
"candidate_id": candidate.get("candidate_id"),
|
|
255
|
+
"candidate_path": "browser-review.json",
|
|
256
|
+
"review_artifact": "review.json",
|
|
257
|
+
}
|
|
258
|
+
write_json(task_dir / "task.json", task)
|
|
259
|
+
report_path = task_dir / "execution-report.json"
|
|
260
|
+
report = read_json(report_path)
|
|
261
|
+
report["browser_review"] = [{"candidate_id": candidate.get("candidate_id"), "decision": args.decision, "reviewer": args.reviewer, "evidence": ["browser-review.json", "review.json"]}]
|
|
262
|
+
report["generated_at"] = now()
|
|
263
|
+
write_json(report_path, report)
|
|
264
|
+
print(json.dumps({"status": "review-recorded", "decision": args.decision, "task_dir": str(task_dir)}, ensure_ascii=False))
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def record_structure(args: argparse.Namespace) -> int:
|
|
269
|
+
task_dir = Path(args.task_dir).resolve()
|
|
270
|
+
report_path = task_dir / "execution-report.json"
|
|
271
|
+
report = read_json(report_path)
|
|
272
|
+
structure = report.setdefault("structure_review", {"missing_files": [], "broken_references": [], "untracked_artifacts": []})
|
|
273
|
+
for key, values in (("missing_files", args.missing_file), ("broken_references", args.broken_reference), ("untracked_artifacts", args.untracked_artifact)):
|
|
274
|
+
for value in values:
|
|
275
|
+
if value not in structure.setdefault(key, []):
|
|
276
|
+
structure[key].append(value)
|
|
277
|
+
report["generated_at"] = now()
|
|
278
|
+
write_json(report_path, report)
|
|
279
|
+
print(json.dumps({"status": "structure-recorded", "task_dir": str(task_dir), "review": structure}, ensure_ascii=False))
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def p1_contract_errors(task_dir: Path, task: dict) -> list[str]:
|
|
284
|
+
names = ("semantic-lint-report.json", "continuity-report.json", "fix-plan.json")
|
|
285
|
+
present = [name for name in names if (task_dir / name).is_file()]
|
|
286
|
+
if not present:
|
|
287
|
+
return []
|
|
288
|
+
errors = [f"P1 contract requires {name}" for name in names if not (task_dir / name).is_file()]
|
|
289
|
+
if errors:
|
|
290
|
+
return errors
|
|
291
|
+
lint = read_json(task_dir / "semantic-lint-report.json")
|
|
292
|
+
if lint.get("schema_version") != "0.1":
|
|
293
|
+
errors.append("semantic-lint-report.json schema_version must be 0.1")
|
|
294
|
+
if lint.get("task_id") != task.get("task_id") or lint.get("scene") != task.get("scene"):
|
|
295
|
+
errors.append("semantic-lint-report.json must bind task_id and scene to task.json")
|
|
296
|
+
continuity = read_json(task_dir / "continuity-report.json")
|
|
297
|
+
if continuity.get("schema_version") != "0.1":
|
|
298
|
+
errors.append("continuity-report.json schema_version must be 0.1")
|
|
299
|
+
if not continuity.get("scenes"):
|
|
300
|
+
errors.append("continuity-report.json requires scenes")
|
|
301
|
+
plan = read_json(task_dir / "fix-plan.json")
|
|
302
|
+
if plan.get("schema_version") != "0.1" or plan.get("task_id") != task.get("task_id"):
|
|
303
|
+
errors.append("fix-plan.json must use schema 0.1 and bind task_id to task.json")
|
|
304
|
+
for source in plan.get("source_reports", []):
|
|
305
|
+
source_path = task_dir / str(source.get("path", ""))
|
|
306
|
+
if not source_path.is_file():
|
|
307
|
+
errors.append(f"fix-plan source report missing: {source.get('path', '<unknown>')}")
|
|
308
|
+
elif source.get("sha256") != hashlib.sha256(source_path.read_bytes()).hexdigest():
|
|
309
|
+
errors.append(f"fix-plan source report hash mismatch: {source.get('path', '<unknown>')}")
|
|
310
|
+
handoff = read_json(task_dir / "handoff.json")
|
|
311
|
+
if handoff.get("fix_plan", {}).get("path") != "fix-plan.json":
|
|
312
|
+
errors.append("handoff.json must expose fix-plan.json")
|
|
313
|
+
return errors
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def approval_contract_errors(task_dir: Path, task: dict, require_current: bool) -> list[str]:
|
|
317
|
+
errors: list[str] = []
|
|
318
|
+
candidate_path = task_dir / "browser-review.json"
|
|
319
|
+
review_path = task_dir / "review.json"
|
|
320
|
+
candidate = read_json(candidate_path)
|
|
321
|
+
review = read_json(review_path)
|
|
322
|
+
if not candidate_path.is_file():
|
|
323
|
+
return ["ready-for-PR task requires browser-review.json"]
|
|
324
|
+
if not review_path.is_file():
|
|
325
|
+
return ["ready-for-PR task requires review.json"]
|
|
326
|
+
if candidate.get("task_id") != task.get("task_id"):
|
|
327
|
+
errors.append("browser-review candidate task_id does not match task.json")
|
|
328
|
+
if candidate.get("scene") != task.get("scene"):
|
|
329
|
+
errors.append("browser-review candidate scene does not match task.json")
|
|
330
|
+
task_review = task.get("browser_review") or {}
|
|
331
|
+
if task_review.get("candidate_id") != candidate.get("candidate_id"):
|
|
332
|
+
errors.append("task browser_review candidate_id does not match browser-review.json")
|
|
333
|
+
if task_review.get("status") != candidate.get("status"):
|
|
334
|
+
errors.append("task browser_review status does not match browser-review.json")
|
|
335
|
+
if review.get("task_id") != task.get("task_id"):
|
|
336
|
+
errors.append("review.json task_id does not match task.json")
|
|
337
|
+
if review.get("candidate_id") != candidate.get("candidate_id"):
|
|
338
|
+
errors.append("review.json must approve the exact browser-review candidate")
|
|
339
|
+
if review.get("decision") != "approved":
|
|
340
|
+
errors.append("ready-for-PR task requires review.json decision=approved")
|
|
341
|
+
if candidate.get("status") != "approved":
|
|
342
|
+
errors.append("ready-for-PR task requires browser-review.json status=approved")
|
|
343
|
+
if candidate.get("requires_user_approval") is not True:
|
|
344
|
+
errors.append("browser-review candidate must explicitly require user approval")
|
|
345
|
+
expiry = candidate_expiry(candidate)
|
|
346
|
+
if expiry is None:
|
|
347
|
+
errors.append("browser-review candidate expiry must be a timezone-aware timestamp")
|
|
348
|
+
elif require_current and datetime.now(timezone.utc) > expiry:
|
|
349
|
+
errors.append("browser-review candidate has expired before PR readiness")
|
|
350
|
+
reviewed_at_raw = review.get("reviewed_at")
|
|
351
|
+
if not reviewed_at_raw:
|
|
352
|
+
errors.append("review.json reviewed_at is required")
|
|
353
|
+
else:
|
|
354
|
+
try:
|
|
355
|
+
reviewed_at = datetime.fromisoformat(str(reviewed_at_raw).replace("Z", "+00:00"))
|
|
356
|
+
if reviewed_at.tzinfo is None:
|
|
357
|
+
raise ValueError("reviewed_at must include timezone")
|
|
358
|
+
if expiry is not None and reviewed_at.astimezone(timezone.utc) > expiry:
|
|
359
|
+
errors.append("review.json was recorded after candidate expiry")
|
|
360
|
+
except (TypeError, ValueError):
|
|
361
|
+
errors.append("review.json reviewed_at is invalid")
|
|
362
|
+
return errors
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def check_report(args: argparse.Namespace) -> int:
|
|
366
|
+
task_dir = Path(args.task_dir).resolve()
|
|
367
|
+
errors = []
|
|
368
|
+
required = ("task.json", "execution-report.json", "artifact-manifest.json", "handoff.json", "issue-register.json")
|
|
369
|
+
for name in required:
|
|
370
|
+
if not (task_dir / name).is_file():
|
|
371
|
+
errors.append(f"missing {name}")
|
|
372
|
+
task = read_json(task_dir / "task.json")
|
|
373
|
+
report = read_json(task_dir / "execution-report.json")
|
|
374
|
+
manifest = read_json(task_dir / "artifact-manifest.json", {"artifacts": []})
|
|
375
|
+
state = task.get("state")
|
|
376
|
+
if not task.get("task_id") or not task.get("scene"):
|
|
377
|
+
errors.append("task.json requires task_id and scene")
|
|
378
|
+
errors.extend(p1_contract_errors(task_dir, task))
|
|
379
|
+
for section in ("completed", "verified", "not_completed", "problems", "next_agent"):
|
|
380
|
+
if not isinstance(report.get(section), list):
|
|
381
|
+
errors.append(f"execution-report.json requires list section: {section}")
|
|
382
|
+
for artifact in manifest.get("artifacts", []):
|
|
383
|
+
if len(artifact.get("sha256", "")) != 64:
|
|
384
|
+
errors.append(f"artifact has invalid sha256: {artifact.get('path', '<unknown>')}")
|
|
385
|
+
if not (task_dir / artifact.get("path", "")).is_file():
|
|
386
|
+
errors.append(f"artifact path missing: {artifact.get('path', '<unknown>')}")
|
|
387
|
+
if state in {"validated", "ready_for_pr", "confirmed"}:
|
|
388
|
+
quality = read_json(task_dir / "quality-report.json")
|
|
389
|
+
if quality.get("status") != "pass":
|
|
390
|
+
errors.append("validated-or-later task requires quality-report.json status=pass")
|
|
391
|
+
if state in {"ready_for_pr", "confirmed"}:
|
|
392
|
+
errors.extend(approval_contract_errors(task_dir, task, require_current=state == "ready_for_pr"))
|
|
393
|
+
if task.get("browser_review", {}).get("required") and state in {"review_required", "validated", "ready_for_pr", "confirmed"}:
|
|
394
|
+
if not (task_dir / "browser-review.json").is_file():
|
|
395
|
+
errors.append("task requires browser-review.json candidate")
|
|
396
|
+
if state == "confirmed" and not (task.get("commit_sha") or task.get("pr_url")):
|
|
397
|
+
errors.append("confirmed task requires commit_sha or pr_url")
|
|
398
|
+
if errors:
|
|
399
|
+
print(json.dumps({"status": "fail", "task_dir": str(task_dir), "errors": errors}, ensure_ascii=False))
|
|
400
|
+
return 1
|
|
401
|
+
print(json.dumps({"status": "pass", "task_id": task.get("task_id"), "state": state, "task_dir": str(task_dir)}, ensure_ascii=False))
|
|
402
|
+
return 0
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def transition(args: argparse.Namespace) -> int:
|
|
406
|
+
task_dir = Path(args.task_dir).resolve()
|
|
407
|
+
task_path = task_dir / "task.json"
|
|
408
|
+
task = read_json(task_path)
|
|
409
|
+
current = task.get("state")
|
|
410
|
+
target = args.state
|
|
411
|
+
if current not in STATES or target not in STATES:
|
|
412
|
+
print(f"Invalid state: {current} -> {target}", file=sys.stderr)
|
|
413
|
+
return 2
|
|
414
|
+
if target not in TRANSITIONS.get(current, set()):
|
|
415
|
+
print(f"Illegal transition: {current} -> {target}", file=sys.stderr)
|
|
416
|
+
return 2
|
|
417
|
+
if target == "validated":
|
|
418
|
+
quality = read_json(task_dir / "quality-report.json")
|
|
419
|
+
if quality.get("status") != "pass":
|
|
420
|
+
print("validated requires quality-report.json status=pass", file=sys.stderr)
|
|
421
|
+
return 2
|
|
422
|
+
if target == "ready_for_pr" and not (task_dir / "review.json").is_file():
|
|
423
|
+
print("ready_for_pr requires review.json", file=sys.stderr)
|
|
424
|
+
return 2
|
|
425
|
+
if target == "ready_for_pr":
|
|
426
|
+
review = read_json(task_dir / "review.json")
|
|
427
|
+
candidate = read_json(task_dir / "browser-review.json")
|
|
428
|
+
if not candidate_is_current(candidate):
|
|
429
|
+
print("ready_for_pr requires a non-expired browser-review candidate", file=sys.stderr)
|
|
430
|
+
return 2
|
|
431
|
+
if not str(review.get("reviewer") or "").strip():
|
|
432
|
+
print("ready_for_pr requires a non-empty review reviewer", file=sys.stderr)
|
|
433
|
+
return 2
|
|
434
|
+
if review.get("decision") != "approved" or candidate.get("task_id") != task.get("task_id") or candidate.get("scene") != task.get("scene") or not candidate.get("candidate_id") or review.get("candidate_id") != candidate.get("candidate_id") or candidate.get("status") != "approved":
|
|
435
|
+
print("ready_for_pr requires approved review.json for the exact browser-review candidate", file=sys.stderr)
|
|
436
|
+
return 2
|
|
437
|
+
if target == "confirmed" and not (args.commit_sha or args.pr_url):
|
|
438
|
+
print("confirmed requires --commit-sha or --pr-url", file=sys.stderr)
|
|
439
|
+
return 2
|
|
440
|
+
task["state"] = target
|
|
441
|
+
task["updated_at"] = now()
|
|
442
|
+
if args.commit_sha:
|
|
443
|
+
task["commit_sha"] = args.commit_sha
|
|
444
|
+
if args.pr_url:
|
|
445
|
+
task["pr_url"] = args.pr_url
|
|
446
|
+
write_json(task_path, task)
|
|
447
|
+
report_path = task_dir / "execution-report.json"
|
|
448
|
+
report = read_json(report_path)
|
|
449
|
+
report["status"] = target
|
|
450
|
+
report["generated_at"] = now()
|
|
451
|
+
write_json(report_path, report)
|
|
452
|
+
print(json.dumps({"status": "transitioned", "from": current, "to": target, "task_id": task.get("task_id")}, ensure_ascii=False))
|
|
453
|
+
return 0
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def md_table(items: list[dict], columns: list[tuple[str, str]]) -> str:
|
|
457
|
+
if not items:
|
|
458
|
+
return "_None recorded._\n"
|
|
459
|
+
header = "| " + " | ".join(title for title, _ in columns) + " |\n"
|
|
460
|
+
divider = "| " + " | ".join("---" for _ in columns) + " |\n"
|
|
461
|
+
rows = ""
|
|
462
|
+
for item in items:
|
|
463
|
+
rows += "| " + " | ".join(str(item.get(key, "")).replace("|", "\\|") for _, key in columns) + " |\n"
|
|
464
|
+
return header + divider + rows
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def render(args: argparse.Namespace) -> int:
|
|
468
|
+
task_dir = Path(args.task_dir).resolve()
|
|
469
|
+
task = read_json(task_dir / "task.json")
|
|
470
|
+
report = read_json(task_dir / "execution-report.json")
|
|
471
|
+
issues = read_json(task_dir / "issue-register.json", {"issues": []}).get("issues", [])
|
|
472
|
+
manifest = read_json(task_dir / "artifact-manifest.json", {"artifacts": []})
|
|
473
|
+
quality = read_json(task_dir / "quality-report.json", {})
|
|
474
|
+
handoff = read_json(task_dir / "handoff.json", {})
|
|
475
|
+
lint = read_json(task_dir / "semantic-lint-report.json", {})
|
|
476
|
+
continuity = read_json(task_dir / "continuity-report.json", {})
|
|
477
|
+
fix_plan = read_json(task_dir / "fix-plan.json", {})
|
|
478
|
+
lines = [
|
|
479
|
+
f"# Animation Task Report — {task.get('task_id', task_dir.name)}",
|
|
480
|
+
"",
|
|
481
|
+
"## Status",
|
|
482
|
+
f"- Overall: **{task.get('state', report.get('status', 'unknown'))}**",
|
|
483
|
+
f"- Confidence: **{report.get('confidence', 'unknown')}**",
|
|
484
|
+
f"- Scene: `{task.get('scene', '')}`",
|
|
485
|
+
f"- Project: `{task.get('project_name', '')}`",
|
|
486
|
+
f"- Context hash: `{task.get('context_hash', '')}`",
|
|
487
|
+
"",
|
|
488
|
+
"## Completed",
|
|
489
|
+
md_table(report.get("completed", []), [("Item", "summary"), ("Status", "status"), ("Evidence", "evidence")]),
|
|
490
|
+
"## Verified",
|
|
491
|
+
md_table(report.get("verified", []), [("Item", "summary"), ("Status", "status"), ("Evidence", "evidence")]),
|
|
492
|
+
"## Not completed",
|
|
493
|
+
md_table(report.get("not_completed", []), [("Item", "summary"), ("Status", "status"), ("Evidence", "evidence")]),
|
|
494
|
+
"## Problems to fix",
|
|
495
|
+
md_table(issues or report.get("problems", []), [("ID", "id"), ("Severity", "severity"), ("Problem", "summary"), ("Status", "status"), ("Next action", "next_action")]),
|
|
496
|
+
"## Structure review",
|
|
497
|
+
f"- Missing files: `{', '.join(report.get('structure_review', {}).get('missing_files', [])) or 'none'}`",
|
|
498
|
+
f"- Broken references: `{', '.join(report.get('structure_review', {}).get('broken_references', [])) or 'none'}`",
|
|
499
|
+
f"- Artifact count: **{len(manifest.get('artifacts', []))}**",
|
|
500
|
+
f"- Quality gate: **{quality.get('status', 'not-run')}**",
|
|
501
|
+
"",
|
|
502
|
+
"## Browser review",
|
|
503
|
+
md_table(report.get("browser_review", []), [("Candidate", "candidate_id"), ("Decision", "decision"), ("Reviewer", "reviewer"), ("Evidence", "evidence")]),
|
|
504
|
+
"## Semantic motion lint",
|
|
505
|
+
f"- Status: **{lint.get('status', 'not-run')}**; errors: **{lint.get('summary', {}).get('errors', 0)}**; warnings: **{lint.get('summary', {}).get('warnings', 0)}**; blocking: **{lint.get('summary', {}).get('blocking', 0)}**",
|
|
506
|
+
md_table(lint.get("findings", []), [("Rule", "rule_id"), ("Severity", "severity"), ("Confidence", "confidence"), ("Message", "message"), ("Basis", "basis")]),
|
|
507
|
+
"## Multi-scene continuity",
|
|
508
|
+
f"- Status: **{continuity.get('status', 'not-run')}**; scenes: **{continuity.get('summary', {}).get('scene_count', 0)}**; transitions: **{continuity.get('summary', {}).get('transition_count', 0)}**; warnings: **{continuity.get('summary', {}).get('warnings', 0)}**",
|
|
509
|
+
"## Fix plan",
|
|
510
|
+
f"- Status: **{fix_plan.get('status', 'not-run')}**; issues: **{len(fix_plan.get('issues', []))}**; next action: **{fix_plan.get('next_agent', {}).get('action', '')}**",
|
|
511
|
+
md_table(fix_plan.get("issues", []), [("ID", "id"), ("Severity", "severity"), ("Confidence", "confidence"), ("Root cause", "root_cause"), ("Rerun", "rerun_scope"), ("Status", "status")]),
|
|
512
|
+
"",
|
|
513
|
+
"## Recommended next Agent / Skill",
|
|
514
|
+
md_table(report.get("next_agent", handoff.get("next_actions", [])), [("Agent/Skill", "agent"), ("Action", "action"), ("Evidence needed", "evidence_needed")]),
|
|
515
|
+
"## Evidence files",
|
|
516
|
+
md_table(manifest.get("artifacts", []), [("Path", "path"), ("Type", "type"), ("Bytes", "bytes"), ("SHA-256", "sha256")]),
|
|
517
|
+
"",
|
|
518
|
+
f"_Generated at {now()} by `scripts/report.py`._",
|
|
519
|
+
]
|
|
520
|
+
output = Path(args.output).resolve() if args.output else task_dir / "REPORT.md"
|
|
521
|
+
output.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
522
|
+
print(json.dumps({"status": "rendered", "task_id": task.get("task_id", task_dir.name), "report": str(output)}, ensure_ascii=False))
|
|
523
|
+
return 0
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def main() -> int:
|
|
527
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
528
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
529
|
+
init = sub.add_parser("init")
|
|
530
|
+
init.add_argument("--task-id", required=True)
|
|
531
|
+
init.add_argument("--scene", default="pending-scene")
|
|
532
|
+
init.add_argument("--intent", default="Animation task")
|
|
533
|
+
init.add_argument("--project-name", default="")
|
|
534
|
+
init.add_argument("--context-path", default="")
|
|
535
|
+
init.add_argument("--context-hash", default="")
|
|
536
|
+
init.add_argument("--agent", default="motionloom")
|
|
537
|
+
init.add_argument("--output")
|
|
538
|
+
init.set_defaults(func=init_task)
|
|
539
|
+
collect_parser = sub.add_parser("collect")
|
|
540
|
+
collect_parser.add_argument("--task-dir", required=True)
|
|
541
|
+
collect_parser.set_defaults(func=collect)
|
|
542
|
+
transition_parser = sub.add_parser("transition")
|
|
543
|
+
transition_parser.add_argument("--task-dir", required=True)
|
|
544
|
+
transition_parser.add_argument("--state", choices=STATES, required=True)
|
|
545
|
+
transition_parser.add_argument("--commit-sha")
|
|
546
|
+
transition_parser.add_argument("--pr-url")
|
|
547
|
+
transition_parser.set_defaults(func=transition)
|
|
548
|
+
render_parser = sub.add_parser("render")
|
|
549
|
+
render_parser.add_argument("--task-dir", required=True)
|
|
550
|
+
render_parser.add_argument("--output")
|
|
551
|
+
render_parser.set_defaults(func=render)
|
|
552
|
+
add_parser = sub.add_parser("add", help="Record a report item or problem")
|
|
553
|
+
add_parser.add_argument("--task-dir", required=True)
|
|
554
|
+
add_parser.add_argument("--section", choices=["completed", "verified", "not_completed", "problems", "next_agent"], required=True)
|
|
555
|
+
add_parser.add_argument("--id", required=True)
|
|
556
|
+
add_parser.add_argument("--summary", required=True)
|
|
557
|
+
add_parser.add_argument("--status", default="recorded")
|
|
558
|
+
add_parser.add_argument("--evidence", action="append", default=[])
|
|
559
|
+
add_parser.add_argument("--confidence", choices=["high", "medium", "low"])
|
|
560
|
+
add_parser.add_argument("--severity", choices=["P0", "P1", "P2", "P3"])
|
|
561
|
+
add_parser.add_argument("--next-action")
|
|
562
|
+
add_parser.add_argument("--agent")
|
|
563
|
+
add_parser.add_argument("--skill")
|
|
564
|
+
add_parser.add_argument("--evidence-needed", action="append", default=[])
|
|
565
|
+
add_parser.set_defaults(func=add_item)
|
|
566
|
+
review_parser = sub.add_parser("review", help="Persist a human or Agent review decision")
|
|
567
|
+
review_parser.add_argument("--task-dir", required=True)
|
|
568
|
+
review_parser.add_argument("--decision", choices=["pending", "approved", "changes_requested", "rejected"], required=True)
|
|
569
|
+
review_parser.add_argument("--reviewer", required=True)
|
|
570
|
+
review_parser.add_argument("--notes", default="")
|
|
571
|
+
review_parser.add_argument("--feedback", action="append", default=[])
|
|
572
|
+
review_parser.add_argument("--candidate-id")
|
|
573
|
+
review_parser.set_defaults(func=record_review)
|
|
574
|
+
structure_parser = sub.add_parser("structure", help="Record missing files, broken references or untracked artifacts")
|
|
575
|
+
structure_parser.add_argument("--task-dir", required=True)
|
|
576
|
+
structure_parser.add_argument("--missing-file", action="append", default=[])
|
|
577
|
+
structure_parser.add_argument("--broken-reference", action="append", default=[])
|
|
578
|
+
structure_parser.add_argument("--untracked-artifact", action="append", default=[])
|
|
579
|
+
structure_parser.set_defaults(func=record_structure)
|
|
580
|
+
check_parser = sub.add_parser("check", help="Validate semantic completeness of a task bundle")
|
|
581
|
+
check_parser.add_argument("--task-dir", required=True)
|
|
582
|
+
check_parser.set_defaults(func=check_report)
|
|
583
|
+
args = parser.parse_args()
|
|
584
|
+
return args.func(args)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
if __name__ == "__main__":
|
|
588
|
+
sys.exit(main())
|