motionloom 2.0.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 (64) 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 +68 -0
  6. package/CODE_OF_CONDUCT.md +19 -0
  7. package/CONTRIBUTING.md +65 -0
  8. package/README.md +193 -134
  9. package/ROADMAP.md +36 -0
  10. package/SECURITY.md +28 -0
  11. package/SKILL.md +57 -9
  12. package/SUPPORT.md +23 -0
  13. package/agent-card.json +42 -6
  14. package/agent-surfaces.json +79 -0
  15. package/bin/motionloom.mjs +33 -5
  16. package/docs/AGENT-INTEGRATION.md +47 -0
  17. package/docs/CHECKLIST.md +2 -1
  18. package/docs/STATUS.md +33 -0
  19. package/docs/audits/2.1.0-deep-stress-evaluation.md +97 -0
  20. package/docs/audits/ci-replay-remediation-2026-08-13.md +33 -0
  21. package/docs/audits/data/2.1.0-deep-stress-6900.json +329 -0
  22. package/docs/audits/data/deep-stress-latest.json +329 -0
  23. package/docs/audits/external-project-corpus-2026-08-13.md +26 -0
  24. package/docs/releases/2.1.0.md +23 -0
  25. package/docs/releases/2.2.0.md +35 -0
  26. package/docs/releases/npm-publish-from-workstation.md +6 -6
  27. package/examples/agent-consumer/README.md +18 -0
  28. package/examples/agent-consumer/fixture-manifest.json +82 -0
  29. package/package.json +69 -28
  30. package/references/agent-interoperability.md +29 -0
  31. package/references/intelligence-core.md +5 -1
  32. package/schemas/agent-surfaces.schema.json +78 -0
  33. package/schemas/project-memory.schema.json +180 -0
  34. package/schemas/remediation-history.schema.json +23 -0
  35. package/schemas/scene-manifest.schema.json +1 -0
  36. package/schemas/visual-truth.schema.json +80 -0
  37. package/scripts/analyze.py +56 -0
  38. package/scripts/capture-runtime-telemetry.py +119 -0
  39. package/scripts/devlab.py +126 -0
  40. package/scripts/discovery.py +257 -0
  41. package/scripts/docs-audit.py +112 -0
  42. package/scripts/eval-intelligence.py +23 -0
  43. package/scripts/eval-projects.py +156 -0
  44. package/scripts/intelligence.py +106 -6
  45. package/scripts/pr.py +151 -0
  46. package/scripts/prepack-clean.mjs +37 -0
  47. package/scripts/project-memory.py +483 -0
  48. package/scripts/project_memory_loader.py +31 -0
  49. package/scripts/quality-gate.py +43 -3
  50. package/scripts/release-verify.py +52 -0
  51. package/scripts/remediation-learning.py +326 -0
  52. package/scripts/render.py +65 -0
  53. package/scripts/report.py +60 -2
  54. package/scripts/review-hook.py +13 -2
  55. package/scripts/skill-doctor.py +12 -2
  56. package/scripts/to-dotlottie.mjs +26 -20
  57. package/scripts/visual-truth.py +310 -0
  58. package/src/core/analyzer.py +174 -25
  59. package/src/output/browser-review-smoke/manifest.json +1 -0
  60. package/src/output/browser-review-smoke/visual-truth.json +68 -0
  61. package/tests/evals/intelligence-cases.json +10 -0
  62. package/tests/evals/project-corpus.json +51 -0
  63. package/tests/scripts/run_tests.py +111 -1
  64. package/tests/scripts/test_project_memory.py +129 -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())
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ MotionLoom render entrypoint.
4
+
5
+ Style contract: evidence-first, explicit runtime mode and no hidden approval
6
+ side effects. This is the cross-platform equivalent of render.sh and delegates
7
+ to the canonical Python snapshot renderer without requiring Bash.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ import re
15
+ import sys
16
+ from pathlib import Path
17
+
18
+
19
+ SCENE_RE = re.compile(r"^[A-Za-z0-9._-]+$")
20
+
21
+
22
+ def main() -> int:
23
+ parser = argparse.ArgumentParser(description="Render MotionLoom runtime snapshots")
24
+ parser.add_argument("scene")
25
+ parser.add_argument("--repo", default=None, help="Repository root; defaults to the script parent")
26
+ parser.add_argument("--progress", default="0,50,100")
27
+ parser.add_argument("--allow-placeholder", action="store_true")
28
+ args = parser.parse_args()
29
+
30
+ if not SCENE_RE.fullmatch(args.scene):
31
+ parser.error("scene id contains unsafe path characters")
32
+
33
+ repo = Path(args.repo).expanduser().resolve() if args.repo else Path(__file__).resolve().parents[1]
34
+ scene_dir = repo / "src" / "output" / args.scene
35
+ if not scene_dir.is_dir():
36
+ print(f"error: scene directory not found: {scene_dir}", file=sys.stderr)
37
+ return 1
38
+
39
+ try:
40
+ progress = [int(value) for value in args.progress.split(",") if value.strip()]
41
+ except ValueError:
42
+ print("error: progress must be a comma-separated list of integers", file=sys.stderr)
43
+ return 1
44
+
45
+ if not progress or any(value < 0 or value > 100 for value in progress):
46
+ print("error: progress values must be between 0 and 100", file=sys.stderr)
47
+ return 1
48
+
49
+ # Import by path-independent package layout. The repository root is added
50
+ # only for this process; no cwd or shell-specific import assumptions.
51
+ sys.path.insert(0, str(repo))
52
+ from src.core.snapshot import render_snapshots # pylint: disable=import-outside-toplevel
53
+
54
+ result = render_snapshots(
55
+ args.scene,
56
+ scene_dir,
57
+ progress,
58
+ allow_placeholder=args.allow_placeholder or os.environ.get("ALLOW_PLACEHOLDER") == "1",
59
+ )
60
+ print(__import__("json").dumps(result, indent=2))
61
+ return 0
62
+
63
+
64
+ if __name__ == "__main__":
65
+ raise SystemExit(main())
package/scripts/report.py CHANGED
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
  import argparse
7
7
  import hashlib
8
8
  import json
9
+ import shutil
9
10
  import sys
10
11
  from datetime import datetime, timezone
11
12
  from pathlib import Path
@@ -49,6 +50,39 @@ def read_json(path: Path, default: dict | list | None = None):
49
50
  return json.loads(path.read_text(encoding="utf-8"))
50
51
 
51
52
 
53
+ def project_memory_path() -> Path:
54
+ return ROOT / ".motionloom" / "project-memory.json"
55
+
56
+
57
+ def memory_summary() -> dict | None:
58
+ path = project_memory_path()
59
+ if not path.is_file():
60
+ return None
61
+ try:
62
+ memory = read_json(path)
63
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
64
+ except (OSError, json.JSONDecodeError):
65
+ return {"path": ".motionloom/project-memory.json", "status": "invalid"}
66
+ freshness = memory.get("freshness") if isinstance(memory, dict) else {}
67
+ return {
68
+ "path": ".motionloom/project-memory.json",
69
+ "snapshot_path": "project-memory.json",
70
+ "memory_id": memory.get("memory_id"),
71
+ "status": freshness.get("status", "invalid"),
72
+ "sha256": digest,
73
+ "updated_at": memory.get("updated_at"),
74
+ }
75
+
76
+
77
+ def sync_memory_snapshot(task_dir: Path) -> dict | None:
78
+ source = project_memory_path()
79
+ if not source.is_file():
80
+ return None
81
+ destination = task_dir / "project-memory.json"
82
+ shutil.copy2(source, destination)
83
+ return memory_summary()
84
+
85
+
52
86
  def has_symlink_component(path: Path) -> bool:
53
87
  current = path
54
88
  while True:
@@ -78,6 +112,7 @@ def candidate_is_current(candidate: dict) -> bool:
78
112
  def init_task(args: argparse.Namespace) -> int:
79
113
  task_dir = Path(args.output or ROOT / "artifacts" / args.task_id).resolve()
80
114
  timestamp = now()
115
+ memory = memory_summary()
81
116
  task = {
82
117
  "schema_version": "1.0",
83
118
  "task_id": args.task_id,
@@ -92,6 +127,8 @@ def init_task(args: argparse.Namespace) -> int:
92
127
  "created_at": timestamp,
93
128
  "updated_at": timestamp,
94
129
  }
130
+ if memory:
131
+ task["project_memory"] = memory
95
132
  report = {
96
133
  "report_version": "1.0",
97
134
  "task_id": args.task_id,
@@ -103,7 +140,7 @@ def init_task(args: argparse.Namespace) -> int:
103
140
  "problems": [],
104
141
  "structure_review": {"missing_files": [], "broken_references": [], "untracked_artifacts": []},
105
142
  "browser_review": [],
106
- "next_agent": [{"agent": args.agent, "action": "Run project analysis and populate context before generation."}],
143
+ "next_agent": [{"agent": args.agent, "action": "Recover Project Memory and run project analysis before generation.", "evidence_needed": ["project-memory.json", "project-context.json"]}],
107
144
  "generated_at": timestamp,
108
145
  }
109
146
  handoff = {
@@ -114,7 +151,7 @@ def init_task(args: argparse.Namespace) -> int:
114
151
  "state": "created",
115
152
  "summary": "New animation task initialized.",
116
153
  "next_actions": [{"action": "Analyze host project context", "skill": "motionloom", "evidence_needed": ["project-context.json"]}],
117
- "required_artifacts": ["task.json", "execution-report.json", *EVIDENCE_ARTIFACTS],
154
+ "required_artifacts": ["task.json", "execution-report.json", *EVIDENCE_ARTIFACTS, *( ["project-memory.json"] if memory else [] )],
118
155
  "blockers": [],
119
156
  }
120
157
  write_json(task_dir / "task.json", task)
@@ -122,6 +159,8 @@ def init_task(args: argparse.Namespace) -> int:
122
159
  write_json(task_dir / "issue-register.json", {"version": "1.0", "task_id": args.task_id, "issues": []})
123
160
  write_json(task_dir / "handoff.json", handoff)
124
161
  write_json(task_dir / "artifact-manifest.json", {"manifest_version": "1.0", "task_id": args.task_id, "generated_at": timestamp, "artifacts": []})
162
+ if memory:
163
+ sync_memory_snapshot(task_dir)
125
164
  (task_dir / "decision-log.jsonl").write_text("", encoding="utf-8")
126
165
  print(json.dumps({"status": "created", "task_id": args.task_id, "task_dir": str(task_dir)}, ensure_ascii=False))
127
166
  return 0
@@ -130,6 +169,7 @@ def init_task(args: argparse.Namespace) -> int:
130
169
  def collect(args: argparse.Namespace) -> int:
131
170
  task_dir = Path(args.task_dir).resolve()
132
171
  task = read_json(task_dir / "task.json")
172
+ sync_memory_snapshot(task_dir)
133
173
  excluded = {"artifact-manifest.json", "execution-report.json", "decision-log.jsonl"}
134
174
  artifacts = []
135
175
  for path in sorted(task_dir.rglob("*")):
@@ -146,6 +186,8 @@ def collect(args: argparse.Namespace) -> int:
146
186
  handoff = read_json(handoff_path)
147
187
  required = set(handoff.get("required_artifacts", []))
148
188
  required.update(name for name in EVIDENCE_ARTIFACTS if (task_dir / name).is_file())
189
+ if (task_dir / "project-memory.json").is_file():
190
+ required.add("project-memory.json")
149
191
  handoff["required_artifacts"] = sorted(required)
150
192
  write_json(handoff_path, handoff)
151
193
  print(json.dumps({"status": "collected", "task_id": manifest["task_id"], "artifact_count": len(artifacts)}, ensure_ascii=False))
@@ -372,6 +414,8 @@ def check_report(args: argparse.Namespace) -> int:
372
414
  task = read_json(task_dir / "task.json")
373
415
  report = read_json(task_dir / "execution-report.json")
374
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, {})
375
419
  state = task.get("state")
376
420
  if not task.get("task_id") or not task.get("scene"):
377
421
  errors.append("task.json requires task_id and scene")
@@ -384,6 +428,11 @@ def check_report(args: argparse.Namespace) -> int:
384
428
  errors.append(f"artifact has invalid sha256: {artifact.get('path', '<unknown>')}")
385
429
  if not (task_dir / artifact.get("path", "")).is_file():
386
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")
387
436
  if state in {"validated", "ready_for_pr", "confirmed"}:
388
437
  quality = read_json(task_dir / "quality-report.json")
389
438
  if quality.get("status") != "pass":
@@ -475,6 +524,11 @@ def render(args: argparse.Namespace) -> int:
475
524
  lint = read_json(task_dir / "semantic-lint-report.json", {})
476
525
  continuity = read_json(task_dir / "continuity-report.json", {})
477
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 {}
478
532
  lines = [
479
533
  f"# Animation Task Report — {task.get('task_id', task_dir.name)}",
480
534
  "",
@@ -501,6 +555,10 @@ def render(args: argparse.Namespace) -> int:
501
555
  "",
502
556
  "## Browser review",
503
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', []))}**",
504
562
  "## Semantic motion lint",
505
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)}**",
506
564
  md_table(lint.get("findings", []), [("Rule", "rule_id"), ("Severity", "severity"), ("Confidence", "confidence"), ("Message", "message"), ("Basis", "basis")]),
@@ -7,6 +7,7 @@ import argparse
7
7
  import hashlib
8
8
  import json
9
9
  import re
10
+ import shutil
10
11
  import subprocess
11
12
  import sys
12
13
  from datetime import datetime, timedelta, timezone
@@ -68,6 +69,11 @@ def prepare(args: argparse.Namespace) -> int:
68
69
  if not scene:
69
70
  raise ValueError("task.json requires scene")
70
71
  scene_dir, source_path, render_meta, context_path = paths(task_dir, task)
72
+ memory_path = ROOT / ".motionloom" / "project-memory.json"
73
+ memory = read_json(memory_path, {}) if memory_path.is_file() else {}
74
+ memory_status = (memory.get("freshness") or {}).get("status")
75
+ if memory_path.is_file() and memory_status not in {"fresh", None}:
76
+ raise ValueError(f"project memory is {memory_status}; run motionloom memory refresh/analyze before browser review")
71
77
  spec = read_json(scene_dir / "motion-spec.json")
72
78
  context_hash = (spec.get("context_binding") or {}).get("context_sha256") or sha256(context_path)
73
79
  source_hash = sha256(source_path)
@@ -92,6 +98,9 @@ def prepare(args: argparse.Namespace) -> int:
92
98
  "prepared_at": now(),
93
99
  "expires_at": (datetime.now(timezone.utc) + CANDIDATE_TTL).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
94
100
  }
101
+ if memory_path.is_file():
102
+ candidate["project_memory_sha256"] = sha256(memory_path)
103
+ candidate["project_memory_id"] = memory.get("memory_id")
95
104
  (scene_dir / "browser-review.json").write_text(json.dumps(candidate, indent=2) + "\n", encoding="utf-8")
96
105
  task["state"] = "review_required"
97
106
  task["updated_at"] = now()
@@ -109,9 +118,11 @@ def prepare(args: argparse.Namespace) -> int:
109
118
  report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
110
119
  handoff_path = task_dir / "handoff.json"
111
120
  handoff = read_json(handoff_path)
112
- handoff.update({"state": "review_required", "to_agent": "browser-review-agent", "summary": "Open the exact rendered candidate in the internal Dev Lab and obtain user approval before PR.", "next_actions": [{"action": "Open internal Dev Lab candidate", "kind": "browser_review", "agent": "browser-review-agent", "skill": "browser-review", "url": url, "candidate_id": cid, "requires_user_approval": True, "evidence_needed": ["review.json"], "output_artifacts": ["review.json"]}], "required_artifacts": sorted(set(handoff.get("required_artifacts", []) + ["browser-review.json", "review.json", "semantic-lint-benchmark.json", "evidence-verifier-report.json", "runtime-adapters/runtime-evidence.json"]))})
121
+ if memory_path.is_file():
122
+ shutil.copy2(memory_path, task_dir / "project-memory.json")
123
+ handoff.update({"state": "review_required", "to_agent": "browser-review-agent", "summary": "Open the exact rendered candidate in the internal Dev Lab and obtain user approval before PR.", "next_actions": [{"action": "Open internal Dev Lab candidate", "kind": "browser_review", "agent": "browser-review-agent", "skill": "browser-review", "url": url, "candidate_id": cid, "requires_user_approval": True, "evidence_needed": ["review.json"], "output_artifacts": ["review.json"]}], "required_artifacts": sorted(set(handoff.get("required_artifacts", []) + ["browser-review.json", "review.json", "semantic-lint-benchmark.json", "evidence-verifier-report.json", "runtime-adapters/runtime-evidence.json"] + (["project-memory.json"] if memory_path.is_file() else [])))})
113
124
  handoff_path.write_text(json.dumps(handoff, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
114
- subprocess.run(["bash", str(ROOT / "scripts/devlab.sh"), scene, "--prepare-only", str(task_dir)], check=True, capture_output=True, text=True)
125
+ subprocess.run([sys.executable, str(ROOT / "scripts/devlab.py"), scene, "--prepare-only", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
115
126
  subprocess.run([sys.executable, str(ROOT / "scripts/report.py"), "collect", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
116
127
  print(json.dumps({"status": "review_required", "task_id": task["task_id"], "candidate_id": cid, "url": url, "agent": "browser-review-agent", "action": "Open this exact URL in the internal browser; ask the user to inspect frames 0/50/100 and approve or request changes.", "requires_user_approval": True, "output_artifacts": ["review.json"]}, ensure_ascii=False))
117
128
  return 0
@@ -13,7 +13,16 @@ from pathlib import Path
13
13
  ROOT = Path(__file__).resolve().parents[1]
14
14
  REQUIRED_FILES = ["SKILL.md", "agent-card.json", "package.json"]
15
15
  REQUIRED_DIRS = ["scripts", "templates", "references", "schemas"]
16
- REQUIRED_SCRIPT_FILES = ["scripts/report-contract.py", "scripts/review-hook.py", "scripts/quality-gate.py", "scripts/runtime-adapters.mjs"]
16
+ REQUIRED_SCRIPT_FILES = [
17
+ "scripts/report-contract.py",
18
+ "scripts/review-hook.py",
19
+ "scripts/quality-gate.py",
20
+ "scripts/runtime-adapters.mjs",
21
+ "scripts/project-memory.py",
22
+ "scripts/project_memory_loader.py",
23
+ "scripts/analyze.py",
24
+ "scripts/devlab.py",
25
+ ]
17
26
  REQUIRED_SCHEMAS = [
18
27
  "task.schema.json",
19
28
  "execution-report.schema.json",
@@ -26,6 +35,7 @@ REQUIRED_SCHEMAS = [
26
35
  "provenance.schema.json",
27
36
  "capability-registry.schema.json",
28
37
  "motion-ir.schema.json",
38
+ "project-memory.schema.json",
29
39
  ]
30
40
 
31
41
 
@@ -121,7 +131,7 @@ def run() -> int:
121
131
  package_path = ROOT / "package.json"
122
132
  try:
123
133
  package = json.loads(package_path.read_text(encoding="utf-8"))
124
- for script in ("test", "validate", "doctor", "report", "report:check", "review"):
134
+ for script in ("test", "validate", "doctor", "report", "report:check", "review", "memory:bootstrap", "memory:recover", "memory:validate", "devlab", "pack:dotlottie"):
125
135
  if script not in package.get("scripts", {}):
126
136
  warnings.append({"code": "missing_package_script", "message": f"package.json has no {script} script."})
127
137
  except (FileNotFoundError, json.JSONDecodeError) as exc: