motionloom 2.0.0 → 2.1.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 (42) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CODE_OF_CONDUCT.md +19 -0
  3. package/CONTRIBUTING.md +65 -0
  4. package/README.md +187 -134
  5. package/ROADMAP.md +32 -0
  6. package/SECURITY.md +27 -0
  7. package/SKILL.md +33 -8
  8. package/SUPPORT.md +23 -0
  9. package/agent-card.json +21 -6
  10. package/bin/motionloom.mjs +23 -5
  11. package/docs/STATUS.md +33 -0
  12. package/docs/audits/2.1.0-deep-stress-evaluation.md +97 -0
  13. package/docs/audits/data/2.1.0-deep-stress-6900.json +329 -0
  14. package/docs/audits/data/deep-stress-latest.json +329 -0
  15. package/docs/audits/external-project-corpus-2026-08-13.md +26 -0
  16. package/docs/releases/2.1.0.md +23 -0
  17. package/docs/releases/npm-publish-from-workstation.md +6 -6
  18. package/package.json +52 -26
  19. package/references/intelligence-core.md +1 -1
  20. package/schemas/project-memory.schema.json +180 -0
  21. package/scripts/analyze.py +56 -0
  22. package/scripts/capture-runtime-telemetry.py +119 -0
  23. package/scripts/devlab.py +126 -0
  24. package/scripts/docs-audit.py +96 -0
  25. package/scripts/eval-intelligence.py +23 -0
  26. package/scripts/eval-projects.py +156 -0
  27. package/scripts/intelligence.py +106 -6
  28. package/scripts/pr.py +150 -0
  29. package/scripts/prepack-clean.mjs +37 -0
  30. package/scripts/project-memory.py +483 -0
  31. package/scripts/project_memory_loader.py +31 -0
  32. package/scripts/release-verify.py +52 -0
  33. package/scripts/render.py +65 -0
  34. package/scripts/report.py +44 -2
  35. package/scripts/review-hook.py +13 -2
  36. package/scripts/skill-doctor.py +12 -2
  37. package/scripts/to-dotlottie.mjs +26 -20
  38. package/src/core/analyzer.py +174 -25
  39. package/tests/evals/intelligence-cases.json +10 -0
  40. package/tests/evals/project-corpus.json +51 -0
  41. package/tests/scripts/run_tests.py +52 -1
  42. package/tests/scripts/test_project_memory.py +129 -0
package/scripts/pr.py ADDED
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ MotionLoom confirm-to-PR entrypoint.
4
+
5
+ Style contract: review-first and side-effect explicit. OPEN_PR defaults to 0;
6
+ this module preserves the guarded shell workflow while using pathlib and
7
+ subprocess argument arrays so it works on Ubuntu, macOS and Windows.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+
22
+ SCENE_RE = re.compile(r"^[A-Za-z0-9._-]+$")
23
+
24
+
25
+ def run(repo: Path, args: list[str], *, capture: bool = False) -> subprocess.CompletedProcess[str]:
26
+ return subprocess.run(
27
+ args,
28
+ cwd=repo,
29
+ check=True,
30
+ text=True,
31
+ capture_output=capture,
32
+ )
33
+
34
+
35
+ def git_output(repo: Path, args: list[str]) -> str:
36
+ return run(repo, ["git", *args], capture=True).stdout.strip()
37
+
38
+
39
+ def main() -> int:
40
+ parser = argparse.ArgumentParser(description="Validate and commit a reviewed MotionLoom scene")
41
+ parser.add_argument("scene")
42
+ parser.add_argument("title", nargs="?", default=None)
43
+ parser.add_argument("--repo", default=None)
44
+ parser.add_argument("--context", default=None)
45
+ parser.add_argument("--task-dir", default=None)
46
+ parser.add_argument("--open-pr", action="store_true", help="Push and open a PR; default is local-only")
47
+ args = parser.parse_args()
48
+
49
+ if not SCENE_RE.fullmatch(args.scene) or args.scene in {".", ".."}:
50
+ parser.error("scene id contains unsafe branch/path characters")
51
+
52
+ repo = Path(args.repo).expanduser().resolve() if args.repo else Path(__file__).resolve().parents[1]
53
+ scene_dir = repo / "src" / "output" / args.scene
54
+ if not scene_dir.is_dir():
55
+ print(f"error: scene directory not found: {scene_dir}", file=sys.stderr)
56
+ return 1
57
+
58
+ try:
59
+ top_level = Path(git_output(repo, ["rev-parse", "--show-toplevel"])).resolve()
60
+ except (subprocess.CalledProcessError, FileNotFoundError) as exc:
61
+ print(f"error: repository is not a Git clone: {exc}", file=sys.stderr)
62
+ return 1
63
+ if top_level != repo:
64
+ print(f"error: --repo must be the Git repository root ({top_level})", file=sys.stderr)
65
+ return 1
66
+
67
+ if not args.task_dir:
68
+ print("error: --task-dir is required; user review must be persisted before PR", file=sys.stderr)
69
+ return 1
70
+ task_dir = Path(args.task_dir).expanduser()
71
+ if not task_dir.is_absolute():
72
+ task_dir = repo / task_dir
73
+ task_dir = task_dir.resolve()
74
+ try:
75
+ task_dir.relative_to(repo)
76
+ except ValueError:
77
+ print("error: task directory must be inside the repository", file=sys.stderr)
78
+ return 1
79
+
80
+ task_path = task_dir / "task.json"
81
+ try:
82
+ task_data = json.loads(task_path.read_text(encoding="utf-8"))
83
+ except (OSError, json.JSONDecodeError) as exc:
84
+ print(f"error: cannot read task.json: {exc}", file=sys.stderr)
85
+ return 1
86
+ if task_data.get("scene") != args.scene:
87
+ print("error: task.json scene does not match requested scene", file=sys.stderr)
88
+ return 1
89
+
90
+ python = os.environ.get("MOTIONLOOM_PYTHON") or ("python" if os.name == "nt" else "python3")
91
+ quality_args = [
92
+ str(repo / "scripts" / "quality-gate.py"),
93
+ "--scene", args.scene,
94
+ "--context", args.context or str(repo / "project-context.json"),
95
+ "--task-dir", str(task_dir),
96
+ "--require-browser-review",
97
+ ]
98
+ print("== running context-bound quality gate ==")
99
+ run(repo, [python, *quality_args])
100
+ run(repo, [python, str(repo / "scripts" / "review-hook.py"), "validate", "--task-dir", str(task_dir), "--require-approved"])
101
+ run(repo, [python, str(repo / "scripts" / "report.py"), "check", "--task-dir", str(task_dir)])
102
+
103
+ branch = f"fix/{args.scene}"
104
+ try:
105
+ run(repo, ["git", "checkout", "-b", branch])
106
+ except subprocess.CalledProcessError:
107
+ run(repo, ["git", "checkout", branch])
108
+
109
+ task_rel = task_dir.relative_to(repo)
110
+ run(repo, ["git", "add", str(Path("src") / "output" / args.scene), str(task_rel)])
111
+ staged = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=repo).returncode
112
+ if staged == 0:
113
+ print("error: no staged scene changes to commit", file=sys.stderr)
114
+ return 1
115
+
116
+ title = args.title or f"animation: scene '{args.scene}' (verified in Dev Lab)"
117
+ commit_message = (
118
+ f"feat(animation): scene '{args.scene}' — proven in Dev Lab\n\n"
119
+ f"- motion-spec signed (see src/output/{args.scene}/motion-spec.json)\n"
120
+ f"- snapshot frames: 0/50/100% in src/output/{args.scene}/snapshot/\n"
121
+ "- context-bound quality gate: passed\n"
122
+ f"- brand tokens bound from {args.context or 'project-context.json'}"
123
+ )
124
+ run(repo, ["git", "commit", "-m", commit_message])
125
+
126
+ if not args.open_pr and os.environ.get("OPEN_PR") != "1":
127
+ print(f"== committed to {branch} — OPEN_PR=0, push/open PR manually ==")
128
+ return 0
129
+
130
+ if shutil.which("gh") is None:
131
+ print(f"== committed to {branch} — install gh CLI to open the PR ==")
132
+ print(f" git push origin {branch}")
133
+ return 0
134
+
135
+ run(repo, ["git", "push", "-u", "origin", branch])
136
+ body = (
137
+ f"## Scene: {args.scene}\n\n"
138
+ "Verified in the Dev Lab (checklist + snapshot diffs attached).\n"
139
+ "Framework, duration, easing, reduced-motion policy and theme tokens per the signed motion spec.\n\n"
140
+ "### Snapshots\n| 0% | 50% | 100% |\n|---|---|---|\n"
141
+ "| `snapshot/frame-00.png` | `snapshot/frame-50.png` | `snapshot/frame-100.png` |\n\n"
142
+ "Ready to review — comment fixes in the Dev Lab or approve to merge."
143
+ )
144
+ run(repo, ["gh", "pr", "create", "--title", title, "--body", body])
145
+ print(f"== PR opened for scene: {args.scene} ==")
146
+ return 0
147
+
148
+
149
+ if __name__ == "__main__":
150
+ raise SystemExit(main())
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Cross-platform npm prepack cleanup. Do not rely on find/rm so publishing
4
+ * from PowerShell, macOS and Linux produces the same tarball.
5
+ */
6
+ import { readdir, lstat, rm } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const root = join(fileURLToPath(new URL("..", import.meta.url)));
11
+ const ignoredDirectories = new Set(["node_modules", ".git", ".venv", "venv"]);
12
+
13
+ async function clean(directory) {
14
+ let entries = [];
15
+ try {
16
+ entries = await readdir(directory, { withFileTypes: true });
17
+ } catch {
18
+ return;
19
+ }
20
+ await Promise.all(entries.map(async (entry) => {
21
+ const path = join(directory, entry.name);
22
+ if (entry.isDirectory()) {
23
+ if (ignoredDirectories.has(entry.name)) return;
24
+ if (entry.name === "__pycache__") {
25
+ await rm(path, { recursive: true, force: true });
26
+ return;
27
+ }
28
+ await clean(path);
29
+ return;
30
+ }
31
+ if (entry.isFile() && /\.(pyc|pyo)$/.test(entry.name)) {
32
+ await rm(path, { force: true });
33
+ }
34
+ }));
35
+ }
36
+
37
+ await clean(root);
@@ -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())