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,483 @@
1
+ #!/usr/bin/env python3
2
+ """MotionLoom durable Project Memory CLI.
3
+
4
+ The memory is intentionally relocatable: project identity is derived from a
5
+ normalized Git remote when available, not from an absolute checkout path.
6
+ Writes are atomic and use pathlib/standard Python only so the same contract
7
+ runs on Ubuntu, macOS and Windows. Memory is context, not approval.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import re
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+
25
+ def configure_utf8_stdio() -> None:
26
+ """Keep JSON/errors printable for Unicode project paths on Windows."""
27
+ for stream in (sys.stdout, sys.stderr):
28
+ reconfigure = getattr(stream, "reconfigure", None)
29
+ if reconfigure is None:
30
+ continue
31
+ try:
32
+ reconfigure(encoding="utf-8", errors="strict")
33
+ except (OSError, ValueError):
34
+ # Embedded callers may expose a non-reconfigurable stream.
35
+ pass
36
+
37
+
38
+ configure_utf8_stdio()
39
+
40
+
41
+ SCHEMA_VERSION = "1.0"
42
+ EXIT_OK = 0
43
+ EXIT_USAGE = 2
44
+ EXIT_STALE = 10
45
+ EXIT_INVALID = 11
46
+ EXIT_MISSING = 12
47
+
48
+
49
+ def now() -> str:
50
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
51
+
52
+
53
+ def canonical(value: Any) -> bytes:
54
+ return (json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
55
+
56
+
57
+ def sha256_bytes(value: bytes) -> str:
58
+ return hashlib.sha256(value).hexdigest()
59
+
60
+
61
+ def sha256_file(path: Path) -> str | None:
62
+ try:
63
+ digest = hashlib.sha256()
64
+ with path.open("rb") as handle:
65
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
66
+ digest.update(chunk)
67
+ return digest.hexdigest()
68
+ except OSError:
69
+ return None
70
+
71
+
72
+ def read_json(path: Path) -> dict[str, Any] | None:
73
+ try:
74
+ value = json.loads(path.read_text(encoding="utf-8"))
75
+ except (OSError, json.JSONDecodeError):
76
+ return None
77
+ return value if isinstance(value, dict) else None
78
+
79
+
80
+ def write_atomic(path: Path, value: dict[str, Any]) -> None:
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ payload = json.dumps(value, indent=2, ensure_ascii=False) + "\n"
83
+ fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
84
+ try:
85
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
86
+ handle.write(payload)
87
+ handle.flush()
88
+ os.fsync(handle.fileno())
89
+ os.replace(temporary, path)
90
+ finally:
91
+ try:
92
+ Path(temporary).unlink()
93
+ except FileNotFoundError:
94
+ pass
95
+
96
+
97
+ def project_root(args: argparse.Namespace) -> Path:
98
+ return Path(args.project_root or ".").expanduser().resolve()
99
+
100
+
101
+ def memory_path(args: argparse.Namespace, root: Path) -> Path:
102
+ raw = args.memory_path or ".motionloom/project-memory.json"
103
+ path = Path(raw).expanduser()
104
+ return path.resolve() if path.is_absolute() else (root / path).resolve()
105
+
106
+
107
+ def git_remote(root: Path) -> str | None:
108
+ try:
109
+ result = subprocess.run(
110
+ ["git", "config", "--get", "remote.origin.url"],
111
+ cwd=root,
112
+ capture_output=True,
113
+ text=True,
114
+ timeout=5,
115
+ check=False,
116
+ )
117
+ except (OSError, subprocess.SubprocessError):
118
+ return None
119
+ remote = result.stdout.strip()
120
+ return remote or None
121
+
122
+
123
+ def normalize_remote(remote: str | None) -> str | None:
124
+ if not remote:
125
+ return None
126
+ value = remote.strip().lower()
127
+ value = re.sub(r"^git\+", "", value)
128
+ value = re.sub(r"^https?://", "", value)
129
+ value = re.sub(r"^ssh://git@", "", value)
130
+ value = re.sub(r"^git@", "", value)
131
+ value = value.replace(":", "/", 1) if value.startswith("github.com:") else value
132
+ value = value.removesuffix(".git").rstrip("/")
133
+ return value
134
+
135
+
136
+ def project_identity(root: Path) -> tuple[str, str | None, str | None]:
137
+ package = read_json(root / "package.json") or {}
138
+ remote = git_remote(root)
139
+ normalized = normalize_remote(remote)
140
+ if normalized:
141
+ return f"git:{normalized}", remote, package.get("name")
142
+ name = str(package.get("name") or root.name).strip().lower()
143
+ return f"local:{name}", remote, package.get("name")
144
+
145
+
146
+ def fingerprint_inputs(root: Path) -> list[tuple[str, str]]:
147
+ candidates = [
148
+ "package.json",
149
+ "package-lock.json",
150
+ "pnpm-lock.yaml",
151
+ "yarn.lock",
152
+ "bun.lockb",
153
+ "project-manifest.json",
154
+ "requirements.txt",
155
+ "pyproject.toml",
156
+ ]
157
+ found: list[tuple[str, str]] = []
158
+ for relative in candidates:
159
+ path = root / relative
160
+ digest = sha256_file(path)
161
+ if digest:
162
+ found.append((relative.replace("\\", "/"), digest))
163
+ return found
164
+
165
+
166
+ def project_fingerprint(root: Path) -> str:
167
+ inventory = fingerprint_inputs(root)
168
+ return sha256_bytes(canonical(inventory))
169
+
170
+
171
+ def context_info(root: Path, context_arg: str | None) -> dict[str, Any]:
172
+ raw = context_arg or "project-context.json"
173
+ path = Path(raw).expanduser()
174
+ context_path = path.resolve() if path.is_absolute() else (root / path).resolve()
175
+ relative = os.path.relpath(context_path, root).replace("\\", "/")
176
+ context = read_json(context_path)
177
+ return {
178
+ "path": relative,
179
+ "sha256": sha256_file(context_path),
180
+ "schema_version": str(context.get("schema_version")) if context else None,
181
+ "generated_at": context.get("generated_at") if context else None,
182
+ "exists": context is not None,
183
+ }
184
+
185
+
186
+ def base_memory(root: Path, context_arg: str | None) -> dict[str, Any]:
187
+ project_id, remote, package_name = project_identity(root)
188
+ package = read_json(root / "package.json") or {}
189
+ context = context_info(root, context_arg)
190
+ checked = now()
191
+ status = "fresh" if context["exists"] else "needs_context"
192
+ reason = "project-context.json is available" if context["exists"] else "run motionloom analyze before animation work"
193
+ memory = {
194
+ "schema_version": SCHEMA_VERSION,
195
+ "memory_id": "pm-" + re.sub(r"[^a-z0-9._-]+", "-", project_id.lower()).strip("-")[:100],
196
+ "created_at": checked,
197
+ "updated_at": checked,
198
+ "project": {
199
+ "project_id": project_id,
200
+ "name": str(package.get("name") or root.name),
201
+ "root_path": str(root),
202
+ "repository": remote,
203
+ "package_name": package_name,
204
+ },
205
+ "context": {key: context[key] for key in ("path", "sha256", "schema_version", "generated_at")},
206
+ "motion_principles": {
207
+ "duration_ms": None,
208
+ "easing": None,
209
+ "rhythm": None,
210
+ "reduced_motion": "respect prefers-reduced-motion; review exceptions explicitly",
211
+ "notes": [],
212
+ },
213
+ "policies": {
214
+ "asset": {"authoritative_sources": ["project-manifest.json", "assets/library/"], "license_required": True, "unknown_asset_policy": "block"},
215
+ "runtime": {"preferred_frameworks": [], "verified_runtimes": [], "browser_matrix": []},
216
+ },
217
+ "decisions": [],
218
+ "rejected_patterns": [],
219
+ "remediation": [],
220
+ "freshness": {
221
+ "status": status,
222
+ "checked_at": checked,
223
+ "context_hash": context["sha256"],
224
+ "project_fingerprint": project_fingerprint(root),
225
+ "reason": reason,
226
+ },
227
+ "recovery": {
228
+ "last_task_id": None,
229
+ "next_actions": ["Read project context before starting an animation task.", "Use Dev Lab review before any PR confirmation."],
230
+ "last_review_state": None,
231
+ },
232
+ }
233
+ return with_integrity(memory)
234
+
235
+
236
+ def without_integrity(memory: dict[str, Any]) -> dict[str, Any]:
237
+ copy = json.loads(json.dumps(memory))
238
+ copy.pop("integrity", None)
239
+ return copy
240
+
241
+
242
+ def integrity_payload(memory: dict[str, Any]) -> dict[str, Any]:
243
+ """Return the durable payload used to calculate the integrity hash.
244
+
245
+ ``project.root_path`` is a runtime checkout location. It is deliberately
246
+ excluded so relocating a valid project does not look like tampering;
247
+ project identity remains bound to its normalized Git remote/package name.
248
+ """
249
+ payload = without_integrity(memory)
250
+ project = payload.get("project")
251
+ if isinstance(project, dict):
252
+ project.pop("root_path", None)
253
+ return payload
254
+
255
+
256
+ def with_integrity(memory: dict[str, Any]) -> dict[str, Any]:
257
+ result = without_integrity(memory)
258
+ result["integrity"] = {"canonical_sha256": sha256_bytes(canonical(integrity_payload(result)))}
259
+ return result
260
+
261
+
262
+ def invariant_errors(memory: dict[str, Any]) -> list[str]:
263
+ errors: list[str] = []
264
+ required = ["schema_version", "memory_id", "project", "context", "motion_principles", "policies", "decisions", "rejected_patterns", "remediation", "freshness", "recovery"]
265
+ for key in required:
266
+ if key not in memory:
267
+ errors.append(f"missing:{key}")
268
+ if memory.get("schema_version") != SCHEMA_VERSION:
269
+ errors.append("schema_version:unsupported")
270
+ project = memory.get("project") or {}
271
+ if not project.get("project_id"):
272
+ errors.append("project.project_id:missing")
273
+ for field in ("name", "root_path", "repository"):
274
+ if field not in project:
275
+ errors.append(f"project.{field}:missing")
276
+ if not re.match(r"^pm-[a-z0-9][a-z0-9._-]*$", str(memory.get("memory_id", ""))):
277
+ errors.append("memory_id:invalid")
278
+ integrity = (memory.get("integrity") or {}).get("canonical_sha256")
279
+ if integrity and integrity != sha256_bytes(canonical(integrity_payload(memory))):
280
+ errors.append("integrity:hash-mismatch")
281
+ freshness = memory.get("freshness") or {}
282
+ if freshness.get("status") not in {"fresh", "stale", "needs_context", "invalid"}:
283
+ errors.append("freshness.status:invalid")
284
+ return errors
285
+
286
+
287
+ def load_or_fail(path: Path) -> dict[str, Any]:
288
+ memory = read_json(path)
289
+ if memory is None:
290
+ print(json.dumps({"status": "missing", "memory_path": str(path), "error": "memory file is missing or invalid JSON"}, ensure_ascii=False), file=sys.stderr)
291
+ raise SystemExit(EXIT_MISSING)
292
+ errors = invariant_errors(memory)
293
+ if errors:
294
+ print(json.dumps({"status": "invalid", "memory_path": str(path), "errors": errors}, ensure_ascii=False), file=sys.stderr)
295
+ raise SystemExit(EXIT_INVALID)
296
+ return memory
297
+
298
+
299
+ def refresh_memory(memory: dict[str, Any], root: Path, context_arg: str | None) -> dict[str, Any]:
300
+ project_id, remote, package_name = project_identity(root)
301
+ if project_id != memory["project"].get("project_id"):
302
+ raise ValueError(f"project identity mismatch: memory={memory['project'].get('project_id')} current={project_id}")
303
+ context = context_info(root, context_arg or memory["context"].get("path"))
304
+ previous_context = memory["context"].get("sha256")
305
+ previous_fingerprint = memory["freshness"].get("project_fingerprint")
306
+ current_fingerprint = project_fingerprint(root)
307
+ if not context["exists"]:
308
+ status, reason = "needs_context", "project context is missing"
309
+ elif previous_context and previous_context != context["sha256"]:
310
+ status, reason = "stale", "project context hash changed; re-review assumptions"
311
+ elif previous_fingerprint and previous_fingerprint != current_fingerprint:
312
+ status, reason = "stale", "project dependency or manifest fingerprint changed"
313
+ else:
314
+ status, reason = "fresh", "context and project fingerprint match recorded memory"
315
+ memory["updated_at"] = now()
316
+ memory["project"].update({"root_path": str(root), "repository": remote, "package_name": package_name})
317
+ memory["context"] = {key: context[key] for key in ("path", "sha256", "schema_version", "generated_at")}
318
+ memory["freshness"] = {"status": status, "checked_at": now(), "context_hash": context["sha256"], "project_fingerprint": current_fingerprint, "reason": reason}
319
+ return with_integrity(memory)
320
+
321
+
322
+ def emit(value: Any, as_json: bool = False) -> None:
323
+ if as_json:
324
+ print(json.dumps(value, indent=2, ensure_ascii=False))
325
+ return
326
+ if isinstance(value, dict):
327
+ print(json.dumps(value, indent=2, ensure_ascii=False))
328
+ else:
329
+ print(value)
330
+
331
+
332
+ def cmd_init(args: argparse.Namespace) -> int:
333
+ root, path = project_root(args), memory_path(args, project_root(args))
334
+ if path.exists() and not args.force:
335
+ print(f"Project Memory already exists: {path}. Use --force only to replace it.", file=sys.stderr)
336
+ return EXIT_USAGE
337
+ memory = base_memory(root, args.context_path)
338
+ write_atomic(path, memory)
339
+ emit({"status": "created", "memory_path": str(path), "freshness": memory["freshness"], "project_id": memory["project"]["project_id"]}, args.json)
340
+ return EXIT_OK
341
+
342
+
343
+ def cmd_inspect(args: argparse.Namespace) -> int:
344
+ root = project_root(args)
345
+ path = memory_path(args, root)
346
+ memory = load_or_fail(path)
347
+ emit(memory, args.json)
348
+ return EXIT_OK
349
+
350
+
351
+ def cmd_validate(args: argparse.Namespace) -> int:
352
+ root = project_root(args)
353
+ path = memory_path(args, root)
354
+ memory = load_or_fail(path)
355
+ current_id, _, _ = project_identity(root)
356
+ errors = invariant_errors(memory)
357
+ if current_id != memory["project"].get("project_id"):
358
+ errors.append(f"project.identity-mismatch:{current_id}")
359
+ result = {"status": "pass" if not errors else "fail", "memory_path": str(path), "project_id": memory["project"].get("project_id"), "freshness": memory["freshness"], "errors": errors}
360
+ emit(result, args.json)
361
+ return EXIT_OK if not errors else EXIT_INVALID
362
+
363
+
364
+ def cmd_refresh(args: argparse.Namespace) -> int:
365
+ root = project_root(args)
366
+ path = memory_path(args, root)
367
+ memory = load_or_fail(path)
368
+ try:
369
+ refreshed = refresh_memory(memory, root, args.context_path)
370
+ except ValueError as error:
371
+ print(str(error), file=sys.stderr)
372
+ return EXIT_INVALID
373
+ write_atomic(path, refreshed)
374
+ emit({"status": refreshed["freshness"]["status"], "memory_path": str(path), "freshness": refreshed["freshness"]}, args.json)
375
+ return EXIT_STALE if refreshed["freshness"]["status"] == "stale" else EXIT_OK
376
+
377
+
378
+ def cmd_recover(args: argparse.Namespace) -> int:
379
+ root = project_root(args)
380
+ path = memory_path(args, root)
381
+ memory = load_or_fail(path)
382
+ current_id, _, _ = project_identity(root)
383
+ if current_id != memory["project"].get("project_id"):
384
+ print(json.dumps({"status": "invalid", "memory_path": str(path), "error": "project identity mismatch", "expected": current_id, "recorded": memory["project"].get("project_id")}, ensure_ascii=False), file=sys.stderr)
385
+ return EXIT_INVALID
386
+ project = memory["project"]
387
+ current_remote = git_remote(root)
388
+ current_package = (read_json(root / "package.json") or {}).get("name")
389
+ runtime_changed = (
390
+ project.get("root_path") != str(root)
391
+ or project.get("repository") != current_remote
392
+ or project.get("package_name") != current_package
393
+ )
394
+ if runtime_changed:
395
+ project.update({"root_path": str(root), "repository": current_remote, "package_name": current_package})
396
+ write_atomic(path, with_integrity(memory))
397
+ freshness = memory["freshness"]
398
+ recovery = {
399
+ "status": freshness["status"],
400
+ "memory_path": str(path),
401
+ "project": memory["project"],
402
+ "context": memory["context"],
403
+ "motion_principles": memory["motion_principles"],
404
+ "policies": memory["policies"],
405
+ "decisions": memory["decisions"][-args.limit :],
406
+ "rejected_patterns": memory["rejected_patterns"][-args.limit :],
407
+ "remediation": memory["remediation"][-args.limit :],
408
+ "freshness": freshness,
409
+ "recovery": memory["recovery"],
410
+ "instructions": [
411
+ "Treat this memory as project context, not as user approval.",
412
+ "If status is stale or needs_context, refresh/analyze before generating animation.",
413
+ "Revalidate source, manifest, runtime and task bindings before reusing artifacts.",
414
+ ],
415
+ }
416
+ emit(recovery, True)
417
+ return EXIT_STALE if freshness["status"] == "stale" else EXIT_OK
418
+
419
+
420
+ def save_entry(args: argparse.Namespace, kind: str) -> int:
421
+ root = project_root(args)
422
+ path = memory_path(args, root)
423
+ memory = load_or_fail(path)
424
+ if kind == "outcome" and not args.user_confirmed:
425
+ print("record-outcome requires --user-confirmed; unreviewed outcomes are not durable learning signals", file=sys.stderr)
426
+ return EXIT_USAGE
427
+ recorded = now()
428
+ source_task = args.source_task_id
429
+ if kind == "decision":
430
+ entry = {"id": args.id, "recorded_at": recorded, "status": args.status, "summary": args.summary, "rationale": args.rationale or "", "user_confirmed": bool(args.user_confirmed), "source_task_id": source_task, "evidence": args.evidence or []}
431
+ memory["decisions"].append(entry)
432
+ if args.status == "rejected":
433
+ memory["rejected_patterns"].append({"id": args.id, "recorded_at": recorded, "pattern": args.summary, "reason": args.rationale or "", "source_task_id": source_task})
434
+ else:
435
+ entry = {"id": args.id, "recorded_at": recorded, "issue_id": args.issue_id, "summary": args.summary, "root_cause": args.root_cause or "", "resolution": args.resolution or "", "result": args.result, "correction_count": args.correction_count, "rerun_scope": args.rerun_scope or [], "user_confirmed": bool(args.user_confirmed), "source_task_id": source_task}
436
+ memory["remediation"].append(entry)
437
+ memory["updated_at"] = recorded
438
+ memory["recovery"]["next_actions"] = ["Revalidate current context before the next animation task.", "Use the recorded scope to avoid rerunning unrelated scenes."]
439
+ write_atomic(path, with_integrity(memory))
440
+ emit({"status": "recorded", "kind": kind, "id": args.id, "memory_path": str(path), "user_confirmed": bool(args.user_confirmed)}, args.json)
441
+ return EXIT_OK
442
+
443
+
444
+ def add_common(parser: argparse.ArgumentParser) -> None:
445
+ parser.add_argument("--project-root", default=".", help="Host project root; defaults to current directory")
446
+ parser.add_argument("--memory-path", help="Memory path, relative to project root by default")
447
+
448
+
449
+ def build_parser() -> argparse.ArgumentParser:
450
+ parser = argparse.ArgumentParser(description="MotionLoom durable Project Memory")
451
+ sub = parser.add_subparsers(dest="command", required=True)
452
+ init = sub.add_parser("init", help="Create a relocatable project memory")
453
+ add_common(init); init.add_argument("--context-path"); init.add_argument("--force", action="store_true"); init.add_argument("--json", action="store_true"); init.set_defaults(func=cmd_init)
454
+ for name, func in (("inspect", cmd_inspect), ("validate", cmd_validate)):
455
+ item = sub.add_parser(name, help=f"{name.title()} the project memory")
456
+ add_common(item); item.add_argument("--json", action="store_true"); item.set_defaults(func=func)
457
+ refresh = sub.add_parser("refresh", help="Refresh context and dependency freshness")
458
+ add_common(refresh); refresh.add_argument("--context-path"); refresh.add_argument("--json", action="store_true"); refresh.set_defaults(func=cmd_refresh)
459
+ recover = sub.add_parser("recover", help="Emit a compact Agent recovery payload")
460
+ add_common(recover); recover.add_argument("--limit", type=int, default=10); recover.set_defaults(func=cmd_recover)
461
+ decision = sub.add_parser("record-decision", help="Persist a project motion decision")
462
+ add_common(decision); decision.add_argument("--id", required=True); decision.add_argument("--summary", required=True); decision.add_argument("--rationale"); decision.add_argument("--status", choices=["accepted", "rejected", "superseded"], required=True); decision.add_argument("--source-task-id"); decision.add_argument("--evidence", action="append"); decision.add_argument("--user-confirmed", action="store_true"); decision.add_argument("--json", action="store_true"); decision.set_defaults(func=lambda args: save_entry(args, "decision"))
463
+ outcome = sub.add_parser("record-outcome", help="Persist a user-confirmed remediation outcome")
464
+ add_common(outcome); outcome.add_argument("--id", required=True); outcome.add_argument("--issue-id", required=True); outcome.add_argument("--summary", required=True); outcome.add_argument("--root-cause"); outcome.add_argument("--resolution"); outcome.add_argument("--result", choices=["pass", "fail", "partial", "unknown"], required=True); outcome.add_argument("--correction-count", type=int, default=0); outcome.add_argument("--rerun-scope", action="append"); outcome.add_argument("--source-task-id"); outcome.add_argument("--user-confirmed", action="store_true"); outcome.add_argument("--json", action="store_true"); outcome.set_defaults(func=lambda args: save_entry(args, "outcome"))
465
+ return parser
466
+
467
+
468
+ def main() -> int:
469
+ args = build_parser().parse_args()
470
+ try:
471
+ return int(args.func(args))
472
+ except BrokenPipeError:
473
+ return EXIT_OK
474
+ except ValueError as error:
475
+ print(f"MotionLoom memory contract error: {error}", file=sys.stderr)
476
+ return EXIT_INVALID
477
+ except OSError as error:
478
+ print(f"MotionLoom memory I/O error: {error}", file=sys.stderr)
479
+ return EXIT_INVALID
480
+
481
+
482
+ if __name__ == "__main__":
483
+ raise SystemExit(main())
@@ -0,0 +1,31 @@
1
+ """Small import-safe bridge for the cross-platform analyzer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ from pathlib import Path
7
+
8
+
9
+ def _module():
10
+ path = Path(__file__).with_name("project-memory.py")
11
+ spec = importlib.util.spec_from_file_location("motionloom_project_memory", path)
12
+ if spec is None or spec.loader is None:
13
+ raise RuntimeError(f"Cannot load Project Memory module: {path}")
14
+ module = importlib.util.module_from_spec(spec)
15
+ spec.loader.exec_module(module)
16
+ return module
17
+
18
+
19
+ def refresh_if_present(root: Path, context_path: Path, initialize: bool = False):
20
+ module = _module()
21
+ memory_path = (root / ".motionloom" / "project-memory.json").resolve()
22
+ if not memory_path.exists():
23
+ if not initialize:
24
+ return None
25
+ memory = module.base_memory(root, str(context_path))
26
+ module.write_atomic(memory_path, memory)
27
+ return {"status": "initialized", "memory_path": str(memory_path), "freshness": memory["freshness"]}
28
+ memory = module.load_or_fail(memory_path)
29
+ refreshed = module.refresh_memory(memory, root, str(context_path))
30
+ module.write_atomic(memory_path, refreshed)
31
+ return {"status": refreshed["freshness"]["status"], "memory_path": str(memory_path), "freshness": refreshed["freshness"]}
@@ -47,6 +47,14 @@ 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
+
50
58
  def _json(path: Path):
51
59
  try:
52
60
  return json.loads(path.read_text(encoding="utf-8"))
@@ -66,7 +74,7 @@ def _telemetry_bundle_sha256(task_dir: Path) -> str:
66
74
  return hashlib.sha256(json.dumps(entries, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
67
75
 
68
76
 
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]:
77
+ 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) -> list[str]:
70
78
  issues = []
71
79
  manifest_path = scene_dir / "manifest.json"
72
80
  spec_path = scene_dir / "motion-spec.json"
@@ -326,6 +334,36 @@ def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = F
326
334
  issues.append(f"signed attestation required binding source is missing: {field}")
327
335
  except (OSError, ValueError, json.JSONDecodeError) as exc:
328
336
  issues.append(f"signed attestation contract: {exc}")
337
+ if require_visual_truth:
338
+ visual_name = manifest.get("visual_truth")
339
+ if not isinstance(visual_name, str) or not visual_name.strip():
340
+ issues.append("visual truth gate requires manifest.visual_truth")
341
+ else:
342
+ visual_path = (scene_dir / visual_name).resolve()
343
+ if not visual_path.is_file() or scene_dir.resolve() not in visual_path.parents:
344
+ issues.append("manifest.visual_truth must point to an existing file inside the scene directory")
345
+ else:
346
+ try:
347
+ task_id = None
348
+ motion_ir_hash = None
349
+ if task_dir:
350
+ task = _json(task_dir / "task.json")
351
+ task_id = task.get("task_id")
352
+ motion_ir = task_dir / "motion-ir.json"
353
+ motion_ir_hash = _sha256_file(motion_ir) if motion_ir.is_file() else None
354
+ visual_truth = _load_visual_truth()
355
+ visual_issues = visual_truth.validate_report(
356
+ visual_path,
357
+ ROOT,
358
+ scene_dir.name,
359
+ task_id,
360
+ source_sha_for_evidence,
361
+ manifest_sha_for_evidence,
362
+ motion_ir_hash,
363
+ )
364
+ issues.extend(f"visual truth: {issue}" for issue in visual_issues)
365
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
366
+ issues.append(f"visual truth contract: {exc}")
329
367
  return issues
330
368
 
331
369
 
@@ -343,6 +381,7 @@ def main() -> int:
343
381
  parser.add_argument("--require-benchmark", action="store_true")
344
382
  parser.add_argument("--require-telemetry", action="store_true")
345
383
  parser.add_argument("--require-attestation", action="store_true")
384
+ parser.add_argument("--require-visual-truth", action="store_true")
346
385
  parser.add_argument("--attestation")
347
386
  parser.add_argument("--trust-policy")
348
387
  args = parser.parse_args()
@@ -363,14 +402,15 @@ def main() -> int:
363
402
  return 0
364
403
  failed = False
365
404
  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)
405
+ 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)
367
406
  if issues:
368
407
  failed = True
369
408
  print(f"REJECTED {scene_dir.name}:")
370
409
  for issue in issues:
371
410
  print(f" - {issue}")
372
411
  else:
373
- print(f"ACCEPTED {scene_dir.name}: context + spec + runtime snapshots + browser-review candidate + checklist")
412
+ suffix = " + visual-truth contract" if args.require_visual_truth else ""
413
+ print(f"ACCEPTED {scene_dir.name}: context + spec + runtime snapshots + browser-review candidate + checklist{suffix}")
374
414
  return 1 if failed else 0
375
415
 
376
416
 
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ """Verify the immutable metadata chain before an npm/GitHub release."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+
12
+
13
+ ROOT = Path(__file__).resolve().parents[1]
14
+
15
+
16
+ def main() -> int:
17
+ parser = argparse.ArgumentParser(description="Verify package version, changelog and release note alignment.")
18
+ parser.add_argument("--expected-version", default="")
19
+ parser.add_argument("--package", default=str(ROOT / "package.json"))
20
+ parser.add_argument("--changelog", default=str(ROOT / "CHANGELOG.md"))
21
+ parser.add_argument("--release-note", default="")
22
+ parser.add_argument("--tag", default="", help="Optional Git tag; accepts v<version> or <version>")
23
+ args = parser.parse_args()
24
+
25
+ errors: list[str] = []
26
+ package_path = Path(args.package).resolve()
27
+ package = json.loads(package_path.read_text(encoding="utf-8"))
28
+ actual = str(package.get("version", ""))
29
+ expected = str(args.expected_version or actual).removeprefix("v")
30
+ if actual != expected:
31
+ errors.append(f"package version {actual!r} does not match expected {expected!r}")
32
+
33
+ changelog = Path(args.changelog).resolve()
34
+ if not re.search(rf"^## \[{re.escape(expected)}\](?:\s|$)", changelog.read_text(encoding="utf-8"), re.MULTILINE):
35
+ errors.append(f"CHANGELOG.md has no heading for [{expected}]")
36
+
37
+ note = Path(args.release_note) if args.release_note else ROOT / "docs/releases" / f"{expected}.md"
38
+ if not note.is_absolute():
39
+ note = ROOT / note
40
+ if not note.is_file():
41
+ errors.append(f"release note is missing: {note.relative_to(ROOT) if note.is_relative_to(ROOT) else note}")
42
+
43
+ if args.tag and args.tag.removeprefix("v") != expected:
44
+ errors.append(f"tag {args.tag!r} does not match version {expected!r}")
45
+
46
+ report = {"status": "fail" if errors else "pass", "version": actual, "expected_version": expected, "errors": errors}
47
+ print(json.dumps(report, indent=2))
48
+ return 1 if errors else 0
49
+
50
+
51
+ if __name__ == "__main__":
52
+ raise SystemExit(main())