motionloom 2.1.0 → 2.2.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 (38) 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 +27 -0
  6. package/README.md +6 -0
  7. package/ROADMAP.md +9 -5
  8. package/SECURITY.md +3 -2
  9. package/SKILL.md +27 -4
  10. package/agent-card.json +23 -2
  11. package/agent-surfaces.json +79 -0
  12. package/bin/motionloom.mjs +11 -1
  13. package/docs/AGENT-INTEGRATION.md +47 -0
  14. package/docs/CHECKLIST.md +2 -1
  15. package/docs/STATUS.md +1 -1
  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/npm-publish-from-workstation.md +6 -6
  19. package/examples/agent-consumer/README.md +18 -0
  20. package/examples/agent-consumer/fixture-manifest.json +82 -0
  21. package/package.json +22 -7
  22. package/references/agent-interoperability.md +29 -0
  23. package/references/intelligence-core.md +4 -0
  24. package/schemas/agent-surfaces.schema.json +78 -0
  25. package/schemas/remediation-history.schema.json +23 -0
  26. package/schemas/scene-manifest.schema.json +1 -0
  27. package/schemas/visual-truth.schema.json +80 -0
  28. package/scripts/devlab.py +1 -1
  29. package/scripts/discovery.py +257 -0
  30. package/scripts/docs-audit.py +17 -1
  31. package/scripts/pr.py +1 -0
  32. package/scripts/quality-gate.py +43 -3
  33. package/scripts/remediation-learning.py +326 -0
  34. package/scripts/report.py +16 -0
  35. package/scripts/visual-truth.py +310 -0
  36. package/src/output/browser-review-smoke/manifest.json +1 -0
  37. package/src/output/browser-review-smoke/visual-truth.json +68 -0
  38. package/tests/scripts/run_tests.py +59 -0
@@ -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
@@ -414,6 +414,8 @@ def check_report(args: argparse.Namespace) -> int:
414
414
  task = read_json(task_dir / "task.json")
415
415
  report = read_json(task_dir / "execution-report.json")
416
416
  manifest = read_json(task_dir / "artifact-manifest.json", {"artifacts": []})
417
+ scene_manifest_path = ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json"
418
+ scene_manifest = read_json(scene_manifest_path, {})
417
419
  state = task.get("state")
418
420
  if not task.get("task_id") or not task.get("scene"):
419
421
  errors.append("task.json requires task_id and scene")
@@ -426,6 +428,11 @@ def check_report(args: argparse.Namespace) -> int:
426
428
  errors.append(f"artifact has invalid sha256: {artifact.get('path', '<unknown>')}")
427
429
  if not (task_dir / artifact.get("path", "")).is_file():
428
430
  errors.append(f"artifact path missing: {artifact.get('path', '<unknown>')}")
431
+ visual_truth_name = scene_manifest.get("visual_truth")
432
+ if visual_truth_name:
433
+ visual_truth_path = scene_manifest_path.parent / str(visual_truth_name)
434
+ if not visual_truth_path.is_file():
435
+ errors.append("scene manifest visual_truth points to a missing artifact")
429
436
  if state in {"validated", "ready_for_pr", "confirmed"}:
430
437
  quality = read_json(task_dir / "quality-report.json")
431
438
  if quality.get("status") != "pass":
@@ -517,6 +524,11 @@ def render(args: argparse.Namespace) -> int:
517
524
  lint = read_json(task_dir / "semantic-lint-report.json", {})
518
525
  continuity = read_json(task_dir / "continuity-report.json", {})
519
526
  fix_plan = read_json(task_dir / "fix-plan.json", {})
527
+ scene_manifest = read_json(ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json", {})
528
+ visual_truth = read_json(
529
+ ROOT / "src" / "output" / str(task.get("scene", "")) / str(scene_manifest.get("visual_truth", "")),
530
+ {},
531
+ ) if scene_manifest.get("visual_truth") else {}
520
532
  lines = [
521
533
  f"# Animation Task Report — {task.get('task_id', task_dir.name)}",
522
534
  "",
@@ -543,6 +555,10 @@ def render(args: argparse.Namespace) -> int:
543
555
  "",
544
556
  "## Browser review",
545
557
  md_table(report.get("browser_review", []), [("Candidate", "candidate_id"), ("Decision", "decision"), ("Reviewer", "reviewer"), ("Evidence", "evidence")]),
558
+ "## Visual Truth",
559
+ 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)}**",
560
+ f"- Baseline: `{visual_truth.get('frames', {}).get('baseline', {}).get('path', '')}`; candidate: `{visual_truth.get('frames', {}).get('candidate', {}).get('path', '')}`",
561
+ f"- Changed pixels: **{visual_truth.get('comparison', {}).get('changed_pixels', 'not-run')}**; changed regions: **{len(visual_truth.get('comparison', {}).get('regions', []))}**",
546
562
  "## Semantic motion lint",
547
563
  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
564
  md_table(lint.get("findings", []), [("Rule", "rule_id"), ("Severity", "severity"), ("Confidence", "confidence"), ("Message", "message"), ("Basis", "basis")]),