motionloom 2.1.0 → 2.3.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 (46) hide show
  1. package/.agents/skills/motionloom/SKILL.md +14 -0
  2. package/.claude/skills/motionloom.md +5 -0
  3. package/.codex/skills/motionloom.md +11 -0
  4. package/AGENTS.md +17 -0
  5. package/CHANGELOG.md +51 -0
  6. package/README.md +60 -15
  7. package/ROADMAP.md +11 -5
  8. package/SECURITY.md +3 -2
  9. package/SKILL.md +40 -5
  10. package/agent-card.json +42 -4
  11. package/agent-surfaces.json +86 -0
  12. package/bin/motionloom.mjs +26 -3
  13. package/docs/AGENT-INTEGRATION.md +60 -0
  14. package/docs/CHECKLIST.md +7 -1
  15. package/docs/STATUS.md +2 -2
  16. package/docs/audits/ci-replay-remediation-2026-08-13.md +33 -0
  17. package/docs/releases/2.2.0.md +35 -0
  18. package/docs/releases/2.3.0.md +33 -0
  19. package/docs/releases/npm-publish-from-workstation.md +6 -6
  20. package/examples/agent-consumer/README.md +18 -0
  21. package/examples/agent-consumer/ai-generated-pilot/hero-male.json +10 -0
  22. package/examples/agent-consumer/ai-generated-pilot-provenance.json +55 -0
  23. package/examples/agent-consumer/fixture-manifest.json +82 -0
  24. package/package.json +31 -7
  25. package/references/agent-interoperability.md +40 -0
  26. package/references/intelligence-core.md +12 -2
  27. package/schemas/agent-surfaces.schema.json +78 -0
  28. package/schemas/asset-provenance.schema.json +183 -0
  29. package/schemas/remediation-history.schema.json +23 -0
  30. package/schemas/scene-manifest.schema.json +2 -0
  31. package/schemas/visual-truth.schema.json +80 -0
  32. package/scripts/asset-provenance.py +390 -0
  33. package/scripts/devlab.py +1 -1
  34. package/scripts/discovery.py +257 -0
  35. package/scripts/docs-audit.py +30 -2
  36. package/scripts/pr.py +3 -0
  37. package/scripts/quality-gate.py +81 -3
  38. package/scripts/remediation-learning.py +326 -0
  39. package/scripts/report.py +66 -0
  40. package/scripts/setup.mjs +472 -0
  41. package/scripts/skill-doctor.py +2 -1
  42. package/scripts/visual-truth.py +310 -0
  43. package/src/output/browser-review-smoke/asset-provenance.json +77 -0
  44. package/src/output/browser-review-smoke/manifest.json +2 -0
  45. package/src/output/browser-review-smoke/visual-truth.json +68 -0
  46. package/tests/scripts/run_tests.py +83 -0
@@ -23,7 +23,7 @@ for markdown in sorted(ROOT.rglob("*.md")):
23
23
  if not (markdown.parent / target).resolve().exists():
24
24
  errors.append(f"{markdown.relative_to(ROOT)} -> missing {target}")
25
25
 
26
- for relative in ["package.json", "agent-card.json", "project-context.example.json", "tests/evals/project-corpus.json"]:
26
+ for relative in ["package.json", "agent-card.json", "agent-surfaces.json", "schemas/agent-surfaces.schema.json", "schemas/provenance.schema.json", "schemas/asset-provenance.schema.json", "schemas/scene-manifest.schema.json", "schemas/visual-truth.schema.json", "schemas/remediation-history.schema.json", "project-context.example.json", "examples/agent-consumer/ai-generated-pilot-provenance.json", "tests/evals/project-corpus.json"]:
27
27
  path = ROOT / relative
28
28
  try:
29
29
  json.loads(path.read_text(encoding="utf-8"))
@@ -36,11 +36,37 @@ for required in ["author", "repository", "homepage", "bugs", "license", "engines
36
36
  errors.append(f"package.json: missing public metadata {required}")
37
37
  if package.get("packageManager") != "pnpm@11.20.0":
38
38
  errors.append("package.json: packageManager must pin pnpm@11.20.0")
39
+ for required_surface in [".agents", ".claude", ".codex", "AGENTS.md", "agent-surfaces.json"]:
40
+ if required_surface not in package.get("files", []):
41
+ errors.append(f"package.json: files must include Agent surface {required_surface}")
42
+ for required_path in ["scripts/asset-provenance.py", "schemas/asset-provenance.schema.json", "examples/agent-consumer/ai-generated-pilot-provenance.json"]:
43
+ if required_path not in package.get("files", []):
44
+ errors.append(f"package.json: files must include asset provenance contract {required_path}")
45
+ for onboarding_script in ["setup", "setup:dry", "status", "repair"]:
46
+ if onboarding_script not in package.get("scripts", {}):
47
+ errors.append(f"package.json: missing onboarding script {onboarding_script}")
48
+
49
+ sys.path.insert(0, str(ROOT))
50
+ try:
51
+ from scripts.discovery import validate as validate_discovery
52
+ discovery = validate_discovery(ROOT)
53
+ for discovery_error in discovery.get("errors", []):
54
+ errors.append(f"agent discovery: {discovery_error}")
55
+ except Exception as exc:
56
+ errors.append(f"agent discovery: validator could not load: {exc}")
57
+
58
+ for required_doc in ["docs/AGENT-INTEGRATION.md", "references/agent-interoperability.md"]:
59
+ if not (ROOT / required_doc).is_file():
60
+ errors.append(f"missing Agent interoperability document: {required_doc}")
39
61
 
40
62
  readme = (ROOT / "README.md").read_text(encoding="utf-8")
41
- for heading in ["Why MotionLoom", "Quick start", "Durable Project Memory", "Evidence, trust and review", "Documentation map"]:
63
+ for heading in ["Why MotionLoom", "Quick start", "Durable Project Memory", "Evidence, trust and review", "Asset provenance tiers", "Documentation map"]:
42
64
  if f"## {heading}" not in readme:
43
65
  errors.append(f"README.md: missing heading {heading}")
66
+ if "npx --yes motionloom setup" not in readme:
67
+ errors.append("README.md: missing one-command onboarding recipe")
68
+ if "npx --yes motionloom setup" not in (ROOT / "docs/AGENT-INTEGRATION.md").read_text(encoding="utf-8"):
69
+ errors.append("docs/AGENT-INTEGRATION.md: missing one-command onboarding recipe")
44
70
 
45
71
  workflow_dir = ROOT / ".github" / "workflows"
46
72
  for workflow in sorted(workflow_dir.glob("*.yml")):
@@ -61,6 +87,8 @@ for workflow in sorted(workflow_dir.glob("*.yml")):
61
87
  for required in ["name:", "on:", "jobs:", "permissions:"]:
62
88
  if required not in text:
63
89
  errors.append(f"{workflow.relative_to(ROOT)}: missing {required}")
90
+ if "--require-asset-provenance" not in (workflow_dir / "quality.yml").read_text(encoding="utf-8"):
91
+ errors.append("quality.yml: missing fail-closed asset provenance production gate")
64
92
  if "pull_request:" in text and "secrets." in text:
65
93
  errors.append(f"{workflow.relative_to(ROOT)}: secrets referenced in pull_request workflow")
66
94
 
package/scripts/pr.py CHANGED
@@ -94,6 +94,8 @@ def main() -> int:
94
94
  "--context", args.context or str(repo / "project-context.json"),
95
95
  "--task-dir", str(task_dir),
96
96
  "--require-browser-review",
97
+ "--require-visual-truth",
98
+ "--require-asset-provenance",
97
99
  ]
98
100
  print("== running context-bound quality gate ==")
99
101
  run(repo, [python, *quality_args])
@@ -120,6 +122,7 @@ def main() -> int:
120
122
  f"- snapshot frames: 0/50/100% in src/output/{args.scene}/snapshot/\n"
121
123
  "- context-bound quality gate: passed\n"
122
124
  f"- brand tokens bound from {args.context or 'project-context.json'}"
125
+ "\n- asset provenance: production_eligible (human approval remains separate)"
123
126
  )
124
127
  run(repo, ["git", "commit", "-m", commit_message])
125
128
 
@@ -47,6 +47,22 @@ def _load_attestation_verifier():
47
47
  return module
48
48
 
49
49
 
50
+ def _load_visual_truth():
51
+ path = ROOT / "scripts" / "visual-truth.py"
52
+ loader = importlib.util.spec_from_file_location("visual_truth", path)
53
+ module = importlib.util.module_from_spec(loader)
54
+ loader.loader.exec_module(module)
55
+ return module
56
+
57
+
58
+ def _load_asset_provenance():
59
+ path = ROOT / "scripts" / "asset-provenance.py"
60
+ loader = importlib.util.spec_from_file_location("asset_provenance", path)
61
+ module = importlib.util.module_from_spec(loader)
62
+ loader.loader.exec_module(module)
63
+ return module
64
+
65
+
50
66
  def _json(path: Path):
51
67
  try:
52
68
  return json.loads(path.read_text(encoding="utf-8"))
@@ -66,7 +82,7 @@ def _telemetry_bundle_sha256(task_dir: Path) -> str:
66
82
  return hashlib.sha256(json.dumps(entries, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
67
83
 
68
84
 
69
- def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = False, task_dir: Path | None = None, require_intelligence: bool = False, require_p1: bool = False, require_benchmark: bool = False, require_telemetry: bool = False, require_attestation: bool = False, attestation_path: Path | None = None, trust_policy_path: Path | None = None) -> list[str]:
85
+ def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = False, task_dir: Path | None = None, require_intelligence: bool = False, require_p1: bool = False, require_benchmark: bool = False, require_telemetry: bool = False, require_attestation: bool = False, attestation_path: Path | None = None, trust_policy_path: Path | None = None, require_visual_truth: bool = False, require_asset_provenance: bool = False, asset_provenance_path: Path | None = None) -> list[str]:
70
86
  issues = []
71
87
  manifest_path = scene_dir / "manifest.json"
72
88
  spec_path = scene_dir / "motion-spec.json"
@@ -100,6 +116,28 @@ def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = F
100
116
  if not (spec.get("accessibility") or {}).get("reduced_motion"):
101
117
  issues.append("motion spec has no reduced-motion policy")
102
118
 
119
+ asset_provenance_name = manifest.get("asset_provenance")
120
+ if require_asset_provenance and not isinstance(asset_provenance_name, str):
121
+ issues.append("asset provenance gate requires manifest.asset_provenance")
122
+ if asset_provenance_name or asset_provenance_path:
123
+ resolved_asset_provenance = asset_provenance_path or (scene_dir / str(asset_provenance_name)).resolve()
124
+ if scene_dir.resolve() not in resolved_asset_provenance.parents or not resolved_asset_provenance.is_file():
125
+ issues.append("manifest.asset_provenance must point to an existing file inside the scene directory")
126
+ else:
127
+ try:
128
+ asset_module = _load_asset_provenance()
129
+ asset_result = asset_module.evaluate(
130
+ resolved_asset_provenance,
131
+ base=scene_dir,
132
+ mode="production" if require_asset_provenance else "runtime",
133
+ manifest=manifest,
134
+ )
135
+ issues.extend(f"asset provenance: {issue}" for issue in asset_result.get("errors", []))
136
+ if require_asset_provenance and not asset_result.get("summary", {}).get("production_eligible"):
137
+ issues.append("asset provenance is not production_eligible; human approval remains separate")
138
+ except (OSError, ValueError, AttributeError) as exc:
139
+ issues.append(f"asset provenance contract: {exc}")
140
+
103
141
  checks = manifest.get("checks")
104
142
  if not isinstance(checks, list) or not checks:
105
143
  issues.append("manifest.checks must contain the Dev Lab quality checklist")
@@ -326,6 +364,36 @@ def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = F
326
364
  issues.append(f"signed attestation required binding source is missing: {field}")
327
365
  except (OSError, ValueError, json.JSONDecodeError) as exc:
328
366
  issues.append(f"signed attestation contract: {exc}")
367
+ if require_visual_truth:
368
+ visual_name = manifest.get("visual_truth")
369
+ if not isinstance(visual_name, str) or not visual_name.strip():
370
+ issues.append("visual truth gate requires manifest.visual_truth")
371
+ else:
372
+ visual_path = (scene_dir / visual_name).resolve()
373
+ if not visual_path.is_file() or scene_dir.resolve() not in visual_path.parents:
374
+ issues.append("manifest.visual_truth must point to an existing file inside the scene directory")
375
+ else:
376
+ try:
377
+ task_id = None
378
+ motion_ir_hash = None
379
+ if task_dir:
380
+ task = _json(task_dir / "task.json")
381
+ task_id = task.get("task_id")
382
+ motion_ir = task_dir / "motion-ir.json"
383
+ motion_ir_hash = _sha256_file(motion_ir) if motion_ir.is_file() else None
384
+ visual_truth = _load_visual_truth()
385
+ visual_issues = visual_truth.validate_report(
386
+ visual_path,
387
+ ROOT,
388
+ scene_dir.name,
389
+ task_id,
390
+ source_sha_for_evidence,
391
+ manifest_sha_for_evidence,
392
+ motion_ir_hash,
393
+ )
394
+ issues.extend(f"visual truth: {issue}" for issue in visual_issues)
395
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
396
+ issues.append(f"visual truth contract: {exc}")
329
397
  return issues
330
398
 
331
399
 
@@ -343,6 +411,9 @@ def main() -> int:
343
411
  parser.add_argument("--require-benchmark", action="store_true")
344
412
  parser.add_argument("--require-telemetry", action="store_true")
345
413
  parser.add_argument("--require-attestation", action="store_true")
414
+ parser.add_argument("--require-visual-truth", action="store_true")
415
+ parser.add_argument("--require-asset-provenance", action="store_true")
416
+ parser.add_argument("--asset-provenance")
346
417
  parser.add_argument("--attestation")
347
418
  parser.add_argument("--trust-policy")
348
419
  args = parser.parse_args()
@@ -357,20 +428,27 @@ def main() -> int:
357
428
  task_dir = Path(args.task_dir).resolve() if args.task_dir else None
358
429
  attestation_path = Path(args.attestation).resolve() if args.attestation else None
359
430
  trust_policy_path = Path(args.trust_policy).resolve() if args.trust_policy else None
431
+ asset_provenance_path = Path(args.asset_provenance).resolve() if args.asset_provenance else None
360
432
  scenes = [root / "src" / "output" / args.scene] if args.scene else sorted(p for p in output_root.iterdir() if p.is_dir()) if output_root.exists() else []
361
433
  if not scenes:
362
434
  print("QUALITY GATE: no scene outputs found")
363
435
  return 0
364
436
  failed = False
365
437
  for scene_dir in scenes:
366
- issues = validate_scene(scene_dir, context, args.require_browser_review, task_dir, args.require_intelligence, args.require_p1, args.require_benchmark, args.require_telemetry, args.require_attestation, attestation_path, trust_policy_path)
438
+ issues = validate_scene(scene_dir, context, args.require_browser_review, task_dir, args.require_intelligence, args.require_p1, args.require_benchmark, args.require_telemetry, args.require_attestation, attestation_path, trust_policy_path, args.require_visual_truth, args.require_asset_provenance, asset_provenance_path)
367
439
  if issues:
368
440
  failed = True
369
441
  print(f"REJECTED {scene_dir.name}:")
370
442
  for issue in issues:
371
443
  print(f" - {issue}")
372
444
  else:
373
- print(f"ACCEPTED {scene_dir.name}: context + spec + runtime snapshots + browser-review candidate + checklist")
445
+ suffixes = []
446
+ if args.require_visual_truth:
447
+ suffixes.append("visual-truth contract")
448
+ if args.require_asset_provenance:
449
+ suffixes.append("asset provenance production eligibility")
450
+ suffix = f" + {' + '.join(suffixes)}" if suffixes else ""
451
+ print(f"ACCEPTED {scene_dir.name}: context + spec + runtime snapshots + browser-review candidate + checklist{suffix}")
374
452
  return 1 if failed else 0
375
453
 
376
454
 
@@ -0,0 +1,326 @@
1
+ #!/usr/bin/env python3
2
+ """Record and summarize user-confirmed remediation and benchmark history.
3
+
4
+ The history is an append-only JSONL ledger. Each event carries a hash of its
5
+ canonical payload and the hash of the previous event. This makes the ledger
6
+ portable and inspectable without introducing a database or treating metrics as
7
+ approval. Only explicitly user-confirmed remediation outcomes contribute to
8
+ acceptance metrics.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import hashlib
15
+ import json
16
+ import math
17
+ import os
18
+ import sys
19
+ from collections import defaultdict
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+
25
+ ROOT = Path(__file__).resolve().parents[1]
26
+ SCHEMA_VERSION = "0.1"
27
+
28
+
29
+ def now() -> str:
30
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
31
+
32
+
33
+ def canonical(value: Any) -> bytes:
34
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
35
+
36
+
37
+ def sha256_bytes(value: bytes) -> str:
38
+ return hashlib.sha256(value).hexdigest()
39
+
40
+
41
+ def event_digest(event: dict[str, Any]) -> str:
42
+ payload = {key: value for key, value in event.items() if key != "event_sha256"}
43
+ return sha256_bytes(canonical(payload))
44
+
45
+
46
+ def history_path(raw: str | None) -> Path:
47
+ return (Path(raw).expanduser() if raw else ROOT / "artifacts" / "remediation-history.jsonl").resolve()
48
+
49
+
50
+ def output_path(raw: str | None, default_name: str) -> Path:
51
+ return (Path(raw).expanduser() if raw else ROOT / "artifacts" / default_name).resolve()
52
+
53
+
54
+ def read_history(path: Path) -> tuple[list[dict[str, Any]], list[str]]:
55
+ if not path.exists():
56
+ return [], []
57
+ if path.is_symlink():
58
+ return [], ["history path must not be a symlink"]
59
+ events: list[dict[str, Any]] = []
60
+ errors: list[str] = []
61
+ previous: str | None = None
62
+ seen: set[str] = set()
63
+ try:
64
+ lines = path.read_text(encoding="utf-8").splitlines()
65
+ except (OSError, UnicodeError) as error:
66
+ return [], [f"history read failed: {error}"]
67
+ for line_number, line in enumerate(lines, start=1):
68
+ if not line.strip():
69
+ continue
70
+ try:
71
+ event = json.loads(line)
72
+ except json.JSONDecodeError as error:
73
+ errors.append(f"line {line_number}: invalid JSON: {error.msg}")
74
+ continue
75
+ if not isinstance(event, dict):
76
+ errors.append(f"line {line_number}: event must be an object")
77
+ continue
78
+ event_id = str(event.get("event_id", ""))
79
+ if not event_id:
80
+ errors.append(f"line {line_number}: event_id is required")
81
+ if event_id in seen:
82
+ errors.append(f"line {line_number}: duplicate event_id {event_id}")
83
+ seen.add(event_id)
84
+ if event.get("schema_version") != SCHEMA_VERSION:
85
+ errors.append(f"line {line_number}: unsupported schema_version")
86
+ if event.get("previous_event_sha256") != previous:
87
+ errors.append(f"line {line_number}: previous_event_sha256 does not match ledger head")
88
+ expected = event_digest(event)
89
+ if event.get("event_sha256") != expected:
90
+ errors.append(f"line {line_number}: event_sha256 mismatch")
91
+ previous = expected
92
+ events.append(event)
93
+ return events, errors
94
+
95
+
96
+ def append_event(path: Path, event: dict[str, Any]) -> dict[str, Any]:
97
+ events, errors = read_history(path)
98
+ if errors:
99
+ raise ValueError("cannot append to invalid history: " + "; ".join(errors))
100
+ event = dict(event)
101
+ event.setdefault("schema_version", SCHEMA_VERSION)
102
+ event.setdefault("recorded_at", now())
103
+ event["previous_event_sha256"] = events[-1].get("event_sha256") if events else None
104
+ event["event_sha256"] = event_digest(event)
105
+ path.parent.mkdir(parents=True, exist_ok=True)
106
+ if path.exists() and path.is_symlink():
107
+ raise ValueError("history path must not be a symlink")
108
+ with path.open("a", encoding="utf-8", newline="\n") as handle:
109
+ handle.write(json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n")
110
+ handle.flush()
111
+ os.fsync(handle.fileno())
112
+ return event
113
+
114
+
115
+ def file_ref(raw: str | None) -> dict[str, Any] | None:
116
+ if not raw:
117
+ return None
118
+ path = Path(raw).expanduser().resolve()
119
+ result: dict[str, Any] = {"path": str(path)}
120
+ if path.is_file():
121
+ result["sha256"] = sha256_bytes(path.read_bytes())
122
+ result["bytes"] = path.stat().st_size
123
+ result["exists"] = True
124
+ else:
125
+ result["exists"] = False
126
+ return result
127
+
128
+
129
+ def cmd_record_outcome(args: argparse.Namespace) -> int:
130
+ if args.correction_count < 0:
131
+ raise ValueError("correction-count must be >= 0")
132
+ event = append_event(history_path(args.history), {
133
+ "event_id": args.event_id,
134
+ "event_type": "remediation_outcome",
135
+ "issue_id": args.issue_id,
136
+ "issue_class": args.issue_class or args.issue_id.split(".", 1)[0],
137
+ "summary": args.summary,
138
+ "root_cause": args.root_cause or "",
139
+ "resolution": args.resolution or "",
140
+ "result": args.result,
141
+ "correction_count": args.correction_count,
142
+ "first_pass_accepted": args.result == "pass" and args.correction_count == 0,
143
+ "rerun_scope": args.rerun_scope or [],
144
+ "user_confirmed": True,
145
+ "source_task_id": args.source_task_id,
146
+ "evidence": [ref for raw in (args.evidence or []) if (ref := file_ref(raw))],
147
+ })
148
+ emit({"status": "recorded", "event": event}, args.json)
149
+ return 0
150
+
151
+
152
+ def cmd_record_benchmark(args: argparse.Namespace) -> int:
153
+ if args.iterations <= 0 or args.p95_ms < 0 or args.threshold_ms <= 0:
154
+ raise ValueError("benchmark iterations must be > 0, p95-ms must be >= 0 and threshold-ms must be > 0")
155
+ event = append_event(history_path(args.history), {
156
+ "event_id": args.event_id,
157
+ "event_type": "benchmark_run",
158
+ "operation": args.operation,
159
+ "task_id": args.task_id,
160
+ "scene": args.scene,
161
+ "iterations": args.iterations,
162
+ "p95_ms": args.p95_ms,
163
+ "threshold_ms": args.threshold_ms,
164
+ "status": args.status or ("pass" if args.p95_ms < args.threshold_ms else "fail"),
165
+ "provenance": file_ref(args.evidence),
166
+ })
167
+ emit({"status": "recorded", "event": event}, args.json)
168
+ return 0
169
+
170
+
171
+ def percentile(values: list[int | float], fraction: float) -> int | float | None:
172
+ if not values:
173
+ return None
174
+ ordered = sorted(values)
175
+ index = max(0, math.ceil(len(ordered) * fraction) - 1)
176
+ return ordered[index]
177
+
178
+
179
+ def rate(numerator: int, denominator: int) -> float | None:
180
+ return round(numerator / denominator, 4) if denominator else None
181
+
182
+
183
+ def cmd_summary(args: argparse.Namespace) -> int:
184
+ path = history_path(args.history)
185
+ events, errors = read_history(path)
186
+ if errors:
187
+ emit({"status": "fail", "history": str(path), "errors": errors}, args.json)
188
+ return 1
189
+ outcomes = [event for event in events if event.get("event_type") == "remediation_outcome"]
190
+ confirmed = [event for event in outcomes if event.get("user_confirmed") is True]
191
+ benchmarks = [event for event in events if event.get("event_type") == "benchmark_run"]
192
+ passes = [event for event in confirmed if event.get("result") == "pass"]
193
+ first_passes = [event for event in confirmed if event.get("first_pass_accepted") is True]
194
+ corrections = [int(event.get("correction_count", 0)) for event in confirmed]
195
+ p95_corrections = percentile(corrections, 0.95)
196
+ outlier_threshold = max(3, int(p95_corrections or 0))
197
+ outliers = [
198
+ {"event_id": event.get("event_id"), "issue_id": event.get("issue_id"), "correction_count": event.get("correction_count")}
199
+ for event in confirmed if int(event.get("correction_count", 0)) >= outlier_threshold and int(event.get("correction_count", 0)) > 0
200
+ ]
201
+ by_issue_class: dict[str, list[dict[str, Any]]] = defaultdict(list)
202
+ for event in confirmed:
203
+ by_issue_class[str(event.get("issue_class") or "unknown")].append(event)
204
+ issue_summary = {}
205
+ for issue_class, items in sorted(by_issue_class.items()):
206
+ issue_passes = sum(item.get("result") == "pass" for item in items)
207
+ issue_first_passes = sum(item.get("first_pass_accepted") is True for item in items)
208
+ issue_summary[issue_class] = {
209
+ "outcomes": len(items),
210
+ "passes": issue_passes,
211
+ "success_rate": rate(issue_passes, len(items)),
212
+ "first_pass_acceptance_rate": rate(issue_first_passes, len(items)),
213
+ "average_correction_count": round(sum(int(item.get("correction_count", 0)) for item in items) / len(items), 4),
214
+ }
215
+ benchmark_passes = sum(event.get("status") == "pass" for event in benchmarks)
216
+ summary = {
217
+ "schema_version": SCHEMA_VERSION,
218
+ "summary_id": f"remediation-summary-{path.stem}",
219
+ "status": "pass",
220
+ "history_path": str(path),
221
+ "history_sha256": sha256_bytes(path.read_bytes()) if path.is_file() else None,
222
+ "generated_at": now(),
223
+ "ledger": {"event_count": len(events), "outcomes": len(outcomes), "confirmed_outcomes": len(confirmed), "ignored_unconfirmed_outcomes": len(outcomes) - len(confirmed), "benchmarks": len(benchmarks)},
224
+ "remediation": {
225
+ "passes": len(passes),
226
+ "success_rate": rate(len(passes), len(confirmed)),
227
+ "first_pass_acceptances": len(first_passes),
228
+ "first_pass_acceptance_rate": rate(len(first_passes), len(confirmed)),
229
+ "average_correction_count": round(sum(corrections) / len(corrections), 4) if corrections else None,
230
+ "p95_correction_count": p95_corrections,
231
+ "outlier_threshold": outlier_threshold,
232
+ "outliers": outliers,
233
+ "by_issue_class": issue_summary,
234
+ },
235
+ "benchmarks_summary": {
236
+ "runs": len(benchmarks),
237
+ "passes": benchmark_passes,
238
+ "pass_rate": rate(benchmark_passes, len(benchmarks)),
239
+ "operations": sorted({str(event.get("operation")) for event in benchmarks}),
240
+ },
241
+ "approval": False,
242
+ }
243
+ if args.output:
244
+ write_json(output_path(args.output, "remediation-summary.json"), summary)
245
+ emit(summary, args.json)
246
+ return 0
247
+
248
+
249
+ def cmd_validate(args: argparse.Namespace) -> int:
250
+ path = history_path(args.history)
251
+ events, errors = read_history(path)
252
+ result = {"status": "pass" if not errors else "fail", "history": str(path), "event_count": len(events), "errors": errors, "approval": False}
253
+ emit(result, args.json)
254
+ return 0 if not errors else 1
255
+
256
+
257
+ def write_json(path: Path, value: dict[str, Any]) -> None:
258
+ path.parent.mkdir(parents=True, exist_ok=True)
259
+ temporary = path.with_name(f".{path.name}.tmp-{os.getpid()}")
260
+ temporary.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
261
+ os.replace(temporary, path)
262
+
263
+
264
+ def emit(value: dict[str, Any], as_json: bool) -> None:
265
+ if as_json:
266
+ print(json.dumps(value, indent=2, ensure_ascii=False))
267
+ else:
268
+ print(json.dumps(value, ensure_ascii=False))
269
+
270
+
271
+ def add_common(parser: argparse.ArgumentParser) -> None:
272
+ parser.add_argument("--history", help="Append-only JSONL history path")
273
+ parser.add_argument("--json", action="store_true")
274
+
275
+
276
+ def build_parser() -> argparse.ArgumentParser:
277
+ parser = argparse.ArgumentParser(description="MotionLoom remediation and benchmark history")
278
+ sub = parser.add_subparsers(dest="command", required=True)
279
+ outcome = sub.add_parser("record-outcome", help="Record an explicitly user-confirmed remediation outcome")
280
+ add_common(outcome)
281
+ outcome.add_argument("--event-id", required=True)
282
+ outcome.add_argument("--issue-id", required=True)
283
+ outcome.add_argument("--issue-class")
284
+ outcome.add_argument("--summary", required=True)
285
+ outcome.add_argument("--root-cause")
286
+ outcome.add_argument("--resolution")
287
+ outcome.add_argument("--result", choices=["pass", "fail", "partial", "unknown"], required=True)
288
+ outcome.add_argument("--correction-count", type=int, default=0)
289
+ outcome.add_argument("--rerun-scope", action="append")
290
+ outcome.add_argument("--source-task-id")
291
+ outcome.add_argument("--evidence", action="append")
292
+ outcome.add_argument("--user-confirmed", action="store_true", required=True)
293
+ outcome.set_defaults(func=cmd_record_outcome)
294
+ benchmark = sub.add_parser("record-benchmark", help="Record a deterministic benchmark run")
295
+ add_common(benchmark)
296
+ benchmark.add_argument("--event-id", required=True)
297
+ benchmark.add_argument("--operation", required=True)
298
+ benchmark.add_argument("--task-id", required=True)
299
+ benchmark.add_argument("--scene", required=True)
300
+ benchmark.add_argument("--iterations", type=int, required=True)
301
+ benchmark.add_argument("--p95-ms", type=float, required=True)
302
+ benchmark.add_argument("--threshold-ms", type=float, required=True)
303
+ benchmark.add_argument("--status", choices=["pass", "fail"])
304
+ benchmark.add_argument("--evidence")
305
+ benchmark.set_defaults(func=cmd_record_benchmark)
306
+ summary = sub.add_parser("summary", help="Aggregate confirmed outcomes and benchmark history")
307
+ add_common(summary)
308
+ summary.add_argument("--output")
309
+ summary.set_defaults(func=cmd_summary)
310
+ validate = sub.add_parser("validate", help="Verify the append-only hash chain")
311
+ add_common(validate)
312
+ validate.set_defaults(func=cmd_validate)
313
+ return parser
314
+
315
+
316
+ def main() -> int:
317
+ args = build_parser().parse_args()
318
+ try:
319
+ return int(args.func(args))
320
+ except (OSError, ValueError) as error:
321
+ print(f"MotionLoom remediation contract error: {error}", file=sys.stderr)
322
+ return 11
323
+
324
+
325
+ if __name__ == "__main__":
326
+ raise SystemExit(main())
package/scripts/report.py CHANGED
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  import hashlib
8
+ import importlib.util
8
9
  import json
9
10
  import shutil
10
11
  import sys
@@ -54,6 +55,32 @@ def project_memory_path() -> Path:
54
55
  return ROOT / ".motionloom" / "project-memory.json"
55
56
 
56
57
 
58
+ def asset_provenance_module():
59
+ path = ROOT / "scripts" / "asset-provenance.py"
60
+ loader = importlib.util.spec_from_file_location("motionloom_asset_provenance", path)
61
+ module = importlib.util.module_from_spec(loader)
62
+ loader.loader.exec_module(module)
63
+ return module
64
+
65
+
66
+ def asset_provenance_result(scene_manifest_path: Path, scene_manifest: dict, mode: str = "runtime") -> dict:
67
+ name = scene_manifest.get("asset_provenance")
68
+ if not name:
69
+ return {"status": "not-run", "errors": ["scene manifest has no asset_provenance"]}
70
+ provenance_path = (scene_manifest_path.parent / str(name)).resolve()
71
+ if scene_manifest_path.parent.resolve() not in provenance_path.parents or not provenance_path.is_file():
72
+ return {"status": "fail", "errors": ["scene manifest asset_provenance points to a missing or unsafe artifact"]}
73
+ try:
74
+ return asset_provenance_module().evaluate(
75
+ provenance_path,
76
+ base=scene_manifest_path.parent,
77
+ mode=mode,
78
+ manifest=scene_manifest,
79
+ )
80
+ except (OSError, ValueError, AttributeError) as exc:
81
+ return {"status": "fail", "errors": [f"asset provenance contract: {exc}"]}
82
+
83
+
57
84
  def memory_summary() -> dict | None:
58
85
  path = project_memory_path()
59
86
  if not path.is_file():
@@ -414,6 +441,8 @@ def check_report(args: argparse.Namespace) -> int:
414
441
  task = read_json(task_dir / "task.json")
415
442
  report = read_json(task_dir / "execution-report.json")
416
443
  manifest = read_json(task_dir / "artifact-manifest.json", {"artifacts": []})
444
+ scene_manifest_path = ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json"
445
+ scene_manifest = read_json(scene_manifest_path, {})
417
446
  state = task.get("state")
418
447
  if not task.get("task_id") or not task.get("scene"):
419
448
  errors.append("task.json requires task_id and scene")
@@ -426,6 +455,26 @@ def check_report(args: argparse.Namespace) -> int:
426
455
  errors.append(f"artifact has invalid sha256: {artifact.get('path', '<unknown>')}")
427
456
  if not (task_dir / artifact.get("path", "")).is_file():
428
457
  errors.append(f"artifact path missing: {artifact.get('path', '<unknown>')}")
458
+ visual_truth_name = scene_manifest.get("visual_truth")
459
+ if visual_truth_name:
460
+ visual_truth_path = scene_manifest_path.parent / str(visual_truth_name)
461
+ if not visual_truth_path.is_file():
462
+ errors.append("scene manifest visual_truth points to a missing artifact")
463
+ provenance_mode = "production" if state in {"ready_for_pr", "confirmed"} else "runtime"
464
+ provenance = asset_provenance_result(scene_manifest_path, scene_manifest, provenance_mode)
465
+ if provenance.get("status") == "fail":
466
+ errors.extend(f"asset provenance: {error}" for error in provenance.get("errors", []))
467
+ # Legacy report-contract fixtures may exercise lifecycle/report behavior
468
+ # without materializing a scene manifest. Do not invent provenance for
469
+ # those synthetic tasks. Once a real scene manifest exists, readiness is
470
+ # fail-closed and its asset_provenance reference is mandatory for PR
471
+ # states; the production quality gate remains independently strict when
472
+ # --require-asset-provenance is supplied.
473
+ if state in {"ready_for_pr", "confirmed"} and scene_manifest_path.is_file():
474
+ if provenance.get("status") != "pass":
475
+ errors.append("ready-for-PR or confirmed task requires a passing asset provenance production check")
476
+ elif not provenance.get("summary", {}).get("production_eligible"):
477
+ errors.append("ready-for-PR or confirmed task requires asset provenance production_eligible")
429
478
  if state in {"validated", "ready_for_pr", "confirmed"}:
430
479
  quality = read_json(task_dir / "quality-report.json")
431
480
  if quality.get("status") != "pass":
@@ -517,6 +566,16 @@ def render(args: argparse.Namespace) -> int:
517
566
  lint = read_json(task_dir / "semantic-lint-report.json", {})
518
567
  continuity = read_json(task_dir / "continuity-report.json", {})
519
568
  fix_plan = read_json(task_dir / "fix-plan.json", {})
569
+ scene_manifest = read_json(ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json", {})
570
+ visual_truth = read_json(
571
+ ROOT / "src" / "output" / str(task.get("scene", "")) / str(scene_manifest.get("visual_truth", "")),
572
+ {},
573
+ ) if scene_manifest.get("visual_truth") else {}
574
+ provenance = asset_provenance_result(
575
+ ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json",
576
+ scene_manifest,
577
+ "production" if task.get("state") in {"ready_for_pr", "confirmed"} else "runtime",
578
+ )
520
579
  lines = [
521
580
  f"# Animation Task Report — {task.get('task_id', task_dir.name)}",
522
581
  "",
@@ -543,6 +602,13 @@ def render(args: argparse.Namespace) -> int:
543
602
  "",
544
603
  "## Browser review",
545
604
  md_table(report.get("browser_review", []), [("Candidate", "candidate_id"), ("Decision", "decision"), ("Reviewer", "reviewer"), ("Evidence", "evidence")]),
605
+ "## Visual Truth",
606
+ f"- Status: **{visual_truth.get('status', 'not-run')}**; scene: `{visual_truth.get('scene', task.get('scene', ''))}`; approval: **{visual_truth.get('review_boundary', {}).get('approval', False)}**",
607
+ f"- Baseline: `{visual_truth.get('frames', {}).get('baseline', {}).get('path', '')}`; candidate: `{visual_truth.get('frames', {}).get('candidate', {}).get('path', '')}`",
608
+ f"- Changed pixels: **{visual_truth.get('comparison', {}).get('changed_pixels', 'not-run')}**; changed regions: **{len(visual_truth.get('comparison', {}).get('regions', []))}**",
609
+ "## Asset provenance",
610
+ f"- Status: **{provenance.get('status', 'not-run')}**; authority: **{provenance.get('summary', {}).get('authority', 'unknown')}**; declared readiness: **{provenance.get('summary', {}).get('declared_readiness', 'blocked')}**; effective readiness: **{provenance.get('summary', {}).get('effective_readiness', 'blocked')}**",
611
+ f"- Production eligible: **{provenance.get('summary', {}).get('production_eligible', False)}**; production approved: **{provenance.get('summary', {}).get('production_approved', False)}**; errors: **{len(provenance.get('errors', []))}**",
546
612
  "## Semantic motion lint",
547
613
  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)}**",
548
614
  md_table(lint.get("findings", []), [("Rule", "rule_id"), ("Severity", "severity"), ("Confidence", "confidence"), ("Message", "message"), ("Basis", "basis")]),