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
@@ -10,9 +10,12 @@ in this context, never in assumptions.
10
10
  """
11
11
 
12
12
  import argparse
13
+ import fnmatch
13
14
  import json
15
+ import os
14
16
  import re
15
17
  import sys
18
+ import time
16
19
  from datetime import datetime, timezone
17
20
  from pathlib import Path
18
21
 
@@ -80,6 +83,112 @@ CATEGORIES = {
80
83
  },
81
84
  }
82
85
 
86
+ DEFAULT_IGNORE_DIRS = {
87
+ ".git",
88
+ ".hg",
89
+ ".svn",
90
+ "node_modules",
91
+ ".venv",
92
+ "venv",
93
+ "__pycache__",
94
+ ".motionloom",
95
+ "target",
96
+ "dist",
97
+ "build",
98
+ ".next",
99
+ ".turbo",
100
+ "coverage",
101
+ }
102
+
103
+
104
+ class ScanBudget:
105
+ """Bound repository traversal without hiding that the scan was partial."""
106
+
107
+ def __init__(
108
+ self,
109
+ root: Path,
110
+ *,
111
+ max_files: int | None = 2500,
112
+ max_bytes: int | None = 25_000_000,
113
+ max_seconds: float | None = 10.0,
114
+ ignore_dirs: set[str] | None = None,
115
+ ignore_globs: list[str] | None = None,
116
+ ) -> None:
117
+ self.root = root
118
+ self.max_files = max_files
119
+ self.max_bytes = max_bytes
120
+ self.max_seconds = max_seconds
121
+ self.ignore_dirs = set(DEFAULT_IGNORE_DIRS) | set(ignore_dirs or ())
122
+ self.ignore_globs = list(ignore_globs or [])
123
+ self.started = time.monotonic()
124
+ self.files_scanned = 0
125
+ self.bytes_scanned = 0
126
+ self.truncated = False
127
+ self.truncation_reasons: list[str] = []
128
+
129
+ def _mark(self, reason: str) -> None:
130
+ self.truncated = True
131
+ if reason not in self.truncation_reasons:
132
+ self.truncation_reasons.append(reason)
133
+
134
+ def _relative(self, path: Path) -> str:
135
+ return path.relative_to(self.root).as_posix()
136
+
137
+ def _ignored(self, path: Path) -> bool:
138
+ relative = self._relative(path)
139
+ if any(part in self.ignore_dirs for part in path.relative_to(self.root).parts):
140
+ return True
141
+ return any(fnmatch.fnmatch(relative, pattern) for pattern in self.ignore_globs)
142
+
143
+ def _limited(self, next_size: int = 0) -> bool:
144
+ if self.max_files is not None and self.files_scanned >= self.max_files:
145
+ self._mark("max_files")
146
+ return True
147
+ if self.max_bytes is not None and self.bytes_scanned + next_size > self.max_bytes:
148
+ self._mark("max_bytes")
149
+ return True
150
+ if self.max_seconds is not None and time.monotonic() - self.started >= self.max_seconds:
151
+ self._mark("max_seconds")
152
+ return True
153
+ return False
154
+
155
+ def files(self, suffixes: set[str] | None = None):
156
+ """Yield readable candidate files in stable order until a budget is hit."""
157
+ for directory, dirnames, filenames in os.walk(self.root, topdown=True):
158
+ directory_path = Path(directory)
159
+ dirnames[:] = sorted(
160
+ name for name in dirnames
161
+ if not self._ignored(directory_path / name)
162
+ )
163
+ for filename in sorted(filenames):
164
+ path = directory_path / filename
165
+ if self._ignored(path):
166
+ continue
167
+ if suffixes and path.suffix.lower() not in suffixes:
168
+ continue
169
+ try:
170
+ size = path.stat().st_size
171
+ except OSError:
172
+ continue
173
+ if self._limited(size):
174
+ return
175
+ self.files_scanned += 1
176
+ self.bytes_scanned += size
177
+ yield path
178
+
179
+ def summary(self) -> dict:
180
+ return {
181
+ "max_files": self.max_files,
182
+ "max_bytes": self.max_bytes,
183
+ "max_seconds": self.max_seconds,
184
+ "files_scanned": self.files_scanned,
185
+ "bytes_scanned": self.bytes_scanned,
186
+ "ignored_directories": sorted(self.ignore_dirs),
187
+ "ignore_globs": self.ignore_globs,
188
+ "scan_truncated": self.truncated,
189
+ "truncation_reasons": self.truncation_reasons,
190
+ }
191
+
83
192
 
84
193
  def read_json(path: Path):
85
194
  try:
@@ -91,15 +200,25 @@ def read_json(path: Path):
91
200
  def detect_stack(project_root: Path, pkg: dict | None) -> dict:
92
201
  stack = {"framework": None, "react": False, "vue": False, "react_native": False, "native_web": False}
93
202
  deps = set()
203
+ package_name = ""
94
204
  if pkg:
205
+ package_name = str(pkg.get("name", "")).lower()
95
206
  deps.update(pkg.get("dependencies", {}).keys())
96
207
  deps.update(pkg.get("devDependencies", {}).keys())
97
208
  stack["react"] = bool({"react", "next", "framer-motion"} & deps)
98
209
  stack["vue"] = bool({"vue", "nuxt"} & deps)
99
210
  stack["react_native"] = bool({"react-native"} & deps)
100
211
  stack["native_web"] = bool({"lottie-web", "dotlottie-web", "gsap", "animejs"} & deps)
101
- if "framer-motion" in deps:
212
+ # Prefer the package's own identity over a transitive/dev dependency in a
213
+ # monorepo. This prevents Motion One from being labeled GSAP merely
214
+ # because its root workspace uses GSAP for tests or tooling, and lets
215
+ # Rive packages expose a distinct runtime signal.
216
+ if package_name in {"motion", "motion-one"}:
217
+ stack["framework"] = "motion"
218
+ elif package_name == "framer-motion" or "framer-motion" in deps:
102
219
  stack["framework"] = "framer-motion"
220
+ elif package_name.startswith("rive") or any("rive" in dependency.lower() for dependency in deps):
221
+ stack["framework"] = "rive"
103
222
  elif "gsap" in deps:
104
223
  stack["framework"] = "gsap"
105
224
  elif "dotlottie-web" in deps or "lottie-web" in deps or "@lottiefiles/react-lottie-player" in deps:
@@ -113,7 +232,7 @@ def detect_stack(project_root: Path, pkg: dict | None) -> dict:
113
232
  return stack
114
233
 
115
234
 
116
- def extract_brand_tokens(project_root: Path) -> dict:
235
+ def extract_brand_tokens(project_root: Path, scanner: ScanBudget | None = None) -> dict:
117
236
  tokens = {"primary": None, "accent": None, "palette": [], "fonts": []}
118
237
  # Tailwind config
119
238
  for candidate in ["tailwind.config.js", "tailwind.config.ts", "tailwind.config.mjs"]:
@@ -126,7 +245,7 @@ def extract_brand_tokens(project_root: Path) -> dict:
126
245
  tokens["accent"] = m.group(1).upper()
127
246
  # package.json theme / CSS variables
128
247
  css_vars = {}
129
- css_files = list(project_root.rglob("*.css")) + list(project_root.rglob("*.scss"))
248
+ css_files = list(scanner.files({".css", ".scss"})) if scanner else list(project_root.rglob("*.css")) + list(project_root.rglob("*.scss"))
130
249
  for f in css_files[:20]:
131
250
  text = f.read_text(encoding="utf-8", errors="ignore")
132
251
  for m in re.finditer(r"--([a-z0-9-]+):\s*(#[0-9a-fA-F]{3,8})", text):
@@ -137,14 +256,13 @@ def extract_brand_tokens(project_root: Path) -> dict:
137
256
  return tokens
138
257
 
139
258
 
140
- def detect_motion_language(project_root: Path) -> dict:
259
+ def detect_motion_language(project_root: Path, scanner: ScanBudget | None = None) -> dict:
141
260
  """Gather existing easing/duration conventions from the project."""
142
261
  easings = set()
143
262
  durations = set()
144
- for pattern in ("*.ts", "*.tsx", "*.js", "*.jsx", "*.css"):
145
- for f in project_root.rglob(pattern):
146
- if "node_modules" in f.parts:
147
- continue
263
+ suffixes = {".ts", ".tsx", ".js", ".jsx", ".css"}
264
+ files = scanner.files(suffixes) if scanner else (f for suffix in suffixes for f in project_root.rglob(f"*{suffix}"))
265
+ for f in files:
148
266
  try:
149
267
  text = f.read_text(encoding="utf-8", errors="ignore")
150
268
  except OSError:
@@ -160,30 +278,47 @@ def detect_motion_language(project_root: Path) -> dict:
160
278
  }
161
279
 
162
280
 
163
- def find_existing_animations(project_root: Path) -> list:
281
+ def find_existing_animations(project_root: Path, scanner: ScanBudget | None = None) -> list:
164
282
  found = []
165
- for ext in ("*.lottie", "*.json", "*.riv"):
166
- for f in project_root.rglob(ext):
167
- rel = str(f.relative_to(project_root))
168
- if "node_modules" in rel or ".git" in rel:
283
+ suffixes = {".lottie", ".json", ".riv"}
284
+ files = scanner.files(suffixes) if scanner else (
285
+ f for suffix in (".lottie", ".json", ".riv") for f in project_root.rglob(f"*{suffix}")
286
+ )
287
+ for f in files:
288
+ rel = f.relative_to(project_root).as_posix()
289
+ if f.suffix.lower() == ".json":
290
+ text = f.read_text(encoding="utf-8", errors="ignore")[:200]
291
+ if '"v"' not in text and "anim" not in text.lower():
169
292
  continue
170
- if ext == "*.json":
171
- text = f.read_text(encoding="utf-8", errors="ignore")[:200]
172
- if '"v"' not in text and "anim" not in text.lower():
173
- continue
174
- found.append(rel)
175
- if len(found) >= 15:
176
- return found
293
+ found.append(rel)
294
+ if len(found) >= 15:
295
+ return found
177
296
  return found
178
297
 
179
298
 
180
- def analyze(project_root: str) -> dict:
299
+ def analyze(
300
+ project_root: str,
301
+ *,
302
+ max_files: int | None = 2500,
303
+ max_bytes: int | None = 25_000_000,
304
+ max_seconds: float | None = 10.0,
305
+ ignore_dirs: list[str] | None = None,
306
+ ignore_globs: list[str] | None = None,
307
+ ) -> dict:
181
308
  root = Path(project_root).resolve()
309
+ scanner = ScanBudget(
310
+ root,
311
+ max_files=max_files,
312
+ max_bytes=max_bytes,
313
+ max_seconds=max_seconds,
314
+ ignore_dirs=ignore_dirs,
315
+ ignore_globs=ignore_globs,
316
+ )
182
317
  pkg = read_json(root / "package.json")
183
318
  manifest = read_json(root / "project-manifest.json")
184
319
  readme = (root / "README.md").read_text(encoding="utf-8", errors="ignore")[:3000] if (root / "README.md").exists() else ""
185
320
 
186
- brand = extract_brand_tokens(root)
321
+ brand = extract_brand_tokens(root, scanner)
187
322
  # project-manifest.json is the explicit project contract and therefore
188
323
  # overrides inferred values from Tailwind/CSS when both are present.
189
324
  manifest_brand = (manifest or {}).get("brand") or {}
@@ -198,8 +333,10 @@ def analyze(project_root: str) -> dict:
198
333
  "description": (manifest or {}).get("description") or (pkg or {}).get("description") or "",
199
334
  "stack": detect_stack(root, pkg),
200
335
  "brand": brand,
201
- "motion_language": detect_motion_language(root),
202
- "existing_animations": find_existing_animations(root),
336
+ "motion_language": detect_motion_language(root, scanner),
337
+ "existing_animations": find_existing_animations(root, scanner),
338
+ "scan": scanner.summary(),
339
+ "scan_truncated": scanner.truncated,
203
340
  "manifest_overrides": manifest or {},
204
341
  "source_authority": "project-manifest.json then assets/library/, never invented geometry",
205
342
  }
@@ -210,11 +347,23 @@ def main():
210
347
  parser = argparse.ArgumentParser(description="Analyze a host project and emit its binding context.")
211
348
  parser.add_argument("project_root", nargs="?", default=".")
212
349
  parser.add_argument("--output", help="Context path; defaults to <project_root>/project-context.json")
350
+ parser.add_argument("--max-files", type=int, default=2500)
351
+ parser.add_argument("--max-bytes", type=int, default=25_000_000)
352
+ parser.add_argument("--max-seconds", type=float, default=10.0)
353
+ parser.add_argument("--ignore-dir", action="append", default=[])
354
+ parser.add_argument("--ignore-glob", action="append", default=[])
213
355
  args = parser.parse_args()
214
356
  root = Path(args.project_root).resolve()
215
357
  if not root.is_dir():
216
358
  parser.error(f"project root is not a directory: {root}")
217
- ctx = analyze(str(root))
359
+ ctx = analyze(
360
+ str(root),
361
+ max_files=args.max_files,
362
+ max_bytes=args.max_bytes,
363
+ max_seconds=args.max_seconds,
364
+ ignore_dirs=args.ignore_dir or None,
365
+ ignore_globs=args.ignore_glob or None,
366
+ )
218
367
  out = Path(args.output).resolve() if args.output else root / "project-context.json"
219
368
  out.parent.mkdir(parents=True, exist_ok=True)
220
369
  out.write_text(json.dumps(ctx, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
@@ -126,6 +126,16 @@
126
126
  "id": "p2-attestation-unknown-signer",
127
127
  "class": "negative",
128
128
  "assertion": "A signature whose key id is absent from the configured trust policy is rejected."
129
+ },
130
+ {
131
+ "id": "project-corpus-first-party-pass",
132
+ "class": "positive",
133
+ "assertion": "The analyzer recognizes the first-party MotionLoom project through the labeled corpus harness."
134
+ },
135
+ {
136
+ "id": "project-corpus-insufficient-external-explicit",
137
+ "class": "negative",
138
+ "assertion": "Missing external project checkouts are reported as insufficient evidence instead of being treated as product-value proof."
129
139
  }
130
140
  ]
131
141
  }
@@ -0,0 +1,51 @@
1
+ {
2
+ "schema_version": "1.0",
3
+ "corpus_id": "motionloom-project-corpus-v1",
4
+ "description": "Provenance-labeled analyzer corpus. External checkouts are opt-in and are never fetched by MotionLoom CI.",
5
+ "required_external_projects": 3,
6
+ "projects": [
7
+ {
8
+ "id": "motionloom-first-party",
9
+ "class": "first-party",
10
+ "external": false,
11
+ "scope": "repository",
12
+ "local_path": ".",
13
+ "source": "https://github.com/lenhonbp/MotionLoom",
14
+ "expected": {
15
+ "name": "motionloom"
16
+ }
17
+ },
18
+ {
19
+ "id": "motion-one-external",
20
+ "class": "external",
21
+ "external": true,
22
+ "local_path": "external/framer-motion",
23
+ "source": "https://github.com/motiondivision/motion",
24
+ "expected": {
25
+ "name": "motion-one",
26
+ "framework": "motion"
27
+ }
28
+ },
29
+ {
30
+ "id": "gsap-external",
31
+ "class": "external",
32
+ "external": true,
33
+ "local_path": "external/gsap",
34
+ "source": "https://github.com/greensock/GSAP",
35
+ "expected": {
36
+ "framework": "gsap"
37
+ }
38
+ },
39
+ {
40
+ "id": "rive-external",
41
+ "class": "external",
42
+ "external": true,
43
+ "local_path": "external/rive",
44
+ "source": "https://github.com/rive-app/rive-react",
45
+ "expected": {
46
+ "name": "rive-react",
47
+ "framework": "rive"
48
+ }
49
+ }
50
+ ]
51
+ }
@@ -49,6 +49,45 @@ def test_analyzer_on_fixture():
49
49
  check("analyzer extracts brand primary", ctx.get("brand", {}).get("primary") == "#2563EB")
50
50
 
51
51
 
52
+ def test_analyzer_scan_budget_and_ignore_rules():
53
+ with tempfile.TemporaryDirectory() as td:
54
+ root = Path(td)
55
+ (root / "package.json").write_text(json.dumps({"name": "bounded-fixture", "dependencies": {"gsap": "^3.0.0"}}))
56
+ (root / "ignored-dir").mkdir()
57
+ (root / "ignored-dir" / "ignored.ts").write_text("duration: 999s")
58
+ (root / "src").mkdir()
59
+ for index in range(5):
60
+ (root / "src" / f"scene-{index}.tsx").write_text("const easing = 'ease-out';")
61
+ context = root / "context.json"
62
+ result = subprocess.run([
63
+ sys.executable, str(ROOT / "scripts/analyze.py"), str(root),
64
+ "--output", str(context), "--max-files", "2", "--max-bytes", "100000",
65
+ "--max-seconds", "10", "--ignore-dir", "ignored-dir",
66
+ ], capture_output=True, text=True)
67
+ data = json.loads(context.read_text())
68
+ scan = data.get("scan", {})
69
+ check("bounded analyzer exits cleanly", result.returncode == 0, result.stderr)
70
+ check("bounded analyzer emits scan metadata", scan.get("files_scanned") == 2)
71
+ check("bounded analyzer exposes truncation", data.get("scan_truncated") is True and scan.get("scan_truncated") is True)
72
+ check("bounded analyzer records file limit", "max_files" in scan.get("truncation_reasons", []))
73
+ check("bounded analyzer records ignored directory", "ignored-dir" in scan.get("ignored_directories", []))
74
+
75
+
76
+ def test_project_corpus_harness_is_explicit_about_evidence():
77
+ result = subprocess.run([
78
+ sys.executable, str(ROOT / "scripts/eval-projects.py"),
79
+ "--workspace", str(ROOT), "--require-external", "0", "--allow-insufficient",
80
+ ], capture_output=True, text=True)
81
+ report = json.loads(result.stdout)
82
+ check("project corpus first-party evaluation passes", result.returncode == 0 and report.get("status") == "pass")
83
+ strict = subprocess.run([
84
+ sys.executable, str(ROOT / "scripts/eval-projects.py"),
85
+ "--workspace", str(ROOT), "--allow-insufficient",
86
+ ], capture_output=True, text=True)
87
+ strict_report = json.loads(strict.stdout)
88
+ check("project corpus missing external evidence is explicit", strict.returncode == 0 and strict_report.get("status") == "insufficient_evidence")
89
+
90
+
52
91
  def test_spec_generate_and_validate():
53
92
  with tempfile.TemporaryDirectory() as td:
54
93
  ctx = Path(td) / "project-context.json"
@@ -678,7 +717,7 @@ def test_p1_semantic_continuity_fix_plan():
678
717
  task.update({"task_id": task_id, "scene": scene, "scene_order": order, "project_name": "p1-continuity"})
679
718
  (path / "task.json").write_text(json.dumps(task, indent=2) + "\n")
680
719
  ir = json.loads((path / "motion-ir.json").read_text())
681
- ir.update({"task_id": task_id, "scene": scene, "context_hash": task.get("context_hash")})
720
+ ir.update({"task_id": task_id, "scene": scene, "context_hash": task.get("context_hash") or ir.get("context_hash")})
682
721
  (path / "motion-ir.json").write_text(json.dumps(ir, indent=2) + "\n")
683
722
 
684
723
  continuity = subprocess.run([
@@ -815,10 +854,22 @@ def test_observability_contract():
815
854
  attestation_tests.returncode == 0 and "attestation contract tests: PASS" in attestation_tests.stdout,
816
855
  )
817
856
 
857
+ memory_tests = subprocess.run(
858
+ [sys.executable, str(ROOT / "tests/scripts/test_project_memory.py")],
859
+ capture_output=True,
860
+ text=True,
861
+ )
862
+ check(
863
+ "project memory recovery and cross-platform contract passes",
864
+ memory_tests.returncode == 0 and "project memory contract tests: PASS" in memory_tests.stdout,
865
+ )
866
+
818
867
 
819
868
  if __name__ == "__main__":
820
869
  print("== MotionLoom engine tests ==")
821
870
  test_analyzer_on_fixture()
871
+ test_analyzer_scan_budget_and_ignore_rules()
872
+ test_project_corpus_harness_is_explicit_about_evidence()
822
873
  test_spec_generate_and_validate()
823
874
  test_rig_build_and_pose()
824
875
  test_lottie_scaffold_valid()
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """Project Memory recovery and cross-platform path contract tests."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+
15
+ ROOT = Path(__file__).resolve().parents[2]
16
+ CLI = ROOT / "scripts" / "project-memory.py"
17
+ NODE_CLI = ROOT / "bin" / "motionloom.mjs"
18
+
19
+
20
+ def run_memory(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
21
+ return subprocess.run(
22
+ [sys.executable, str(CLI), *args, "--project-root", str(root)],
23
+ cwd=ROOT,
24
+ capture_output=True,
25
+ text=True,
26
+ check=False,
27
+ )
28
+
29
+
30
+ def test_memory_recovery_and_relocation() -> None:
31
+ with tempfile.TemporaryDirectory(prefix="motionloom memory ") as td:
32
+ root = Path(td) / "Dự án MotionLoom"
33
+ root.mkdir(parents=True)
34
+ (root / "package.json").write_text(json.dumps({"name": "memory-fixture"}), encoding="utf-8")
35
+ (root / "project-context.json").write_text(json.dumps({
36
+ "schema_version": "2.0",
37
+ "name": "memory-fixture",
38
+ "generated_at": "2026-08-13T00:00:00Z",
39
+ }), encoding="utf-8")
40
+
41
+ created = run_memory(root, "init", "--context-path", "project-context.json", "--json")
42
+ assert created.returncode == 0, created.stderr
43
+ memory_path = root / ".motionloom" / "project-memory.json"
44
+ assert memory_path.is_file()
45
+
46
+ decision = run_memory(root, "record-decision", "--id", "ease-ui", "--status", "accepted", "--summary", "Use ease-out for UI entry", "--user-confirmed", "--json")
47
+ assert decision.returncode == 0, decision.stderr
48
+
49
+ rejected = run_memory(root, "record-decision", "--id", "linear-ui", "--status", "rejected", "--summary", "Do not use linear easing for UI", "--rationale", "Perceptually mechanical", "--json")
50
+ assert rejected.returncode == 0, rejected.stderr
51
+
52
+ blocked_outcome = run_memory(root, "record-outcome", "--id", "fix-1", "--issue-id", "issue-1", "--summary", "Unreviewed fix", "--result", "pass")
53
+ assert blocked_outcome.returncode == 2
54
+ assert "user-confirmed" in blocked_outcome.stderr
55
+
56
+ outcome = run_memory(root, "record-outcome", "--id", "fix-1", "--issue-id", "issue-1", "--summary", "Reduce hand-track duration", "--root-cause", "timing", "--resolution", "duration 420ms", "--result", "pass", "--correction-count", "1", "--rerun-scope", "scene:onboarding-wave", "--user-confirmed", "--json")
57
+ assert outcome.returncode == 0, outcome.stderr
58
+
59
+ recovered = run_memory(root, "recover", "--limit", "10")
60
+ assert recovered.returncode == 0, recovered.stderr
61
+ payload = json.loads(recovered.stdout)
62
+ assert payload["project"]["project_id"].startswith("local:")
63
+ assert payload["decisions"][-1]["id"] == "linear-ui"
64
+ assert payload["rejected_patterns"][-1]["id"] == "linear-ui"
65
+ assert payload["remediation"][-1]["user_confirmed"] is True
66
+ assert payload["instructions"][0].endswith("user approval.")
67
+ assert payload["instructions"][-1].endswith("artifacts.")
68
+
69
+ moved = Path(td) / "relocated" / "MotionLoom copy"
70
+ moved.parent.mkdir()
71
+ shutil.copytree(root, moved)
72
+ relocated = run_memory(moved, "recover", "--limit", "10")
73
+ assert relocated.returncode == 0, relocated.stderr
74
+ relocated_payload = json.loads(relocated.stdout)
75
+ assert relocated_payload["project"]["root_path"] == str(moved.resolve())
76
+ relocated_validation = run_memory(moved, "validate", "--json")
77
+ assert relocated_validation.returncode == 0, relocated_validation.stderr
78
+
79
+ # Only the volatile checkout path may be rebound without changing the
80
+ # durable project payload; meaningful direct edits remain integrity failures.
81
+ rebound = json.loads((moved / ".motionloom" / "project-memory.json").read_text(encoding="utf-8"))
82
+ rebound["project"]["root_path"] = str(moved.parent / "another-location")
83
+ (moved / ".motionloom" / "project-memory.json").write_text(json.dumps(rebound), encoding="utf-8")
84
+ path_only = run_memory(moved, "validate", "--json")
85
+ assert path_only.returncode == 0, path_only.stdout + path_only.stderr
86
+
87
+ context = json.loads((moved / "project-context.json").read_text(encoding="utf-8"))
88
+ context["changed_by_other_task"] = True
89
+ (moved / "project-context.json").write_text(json.dumps(context), encoding="utf-8")
90
+ stale = run_memory(moved, "refresh", "--json")
91
+ assert stale.returncode == 10, stale.stdout + stale.stderr
92
+ stale_payload = json.loads(stale.stdout)
93
+ assert stale_payload["freshness"]["status"] == "stale"
94
+
95
+
96
+ def test_cross_project_recovery_is_fail_closed() -> None:
97
+ with tempfile.TemporaryDirectory(prefix="motionloom-project-a ") as td:
98
+ source = Path(td) / "source"
99
+ foreign = Path(td) / "foreign"
100
+ source.mkdir(); foreign.mkdir()
101
+ for root, name in ((source, "project-a"), (foreign, "project-b")):
102
+ (root / "package.json").write_text(json.dumps({"name": name}), encoding="utf-8")
103
+ (root / "project-context.json").write_text(json.dumps({"schema_version": "2.0", "name": name}), encoding="utf-8")
104
+ assert run_memory(source, "init").returncode == 0
105
+ foreign_memory = foreign / ".motionloom"
106
+ foreign_memory.mkdir()
107
+ shutil.copy2(source / ".motionloom" / "project-memory.json", foreign_memory / "project-memory.json")
108
+ result = run_memory(foreign, "recover")
109
+ assert result.returncode == 11
110
+ assert "identity mismatch" in result.stderr
111
+
112
+
113
+ def test_node_cli_routes_memory_without_shell() -> None:
114
+ with tempfile.TemporaryDirectory(prefix="motionloom node cli ") as td:
115
+ root = Path(td)
116
+ (root / "package.json").write_text(json.dumps({"name": "node-cli-fixture"}), encoding="utf-8")
117
+ (root / "project-context.json").write_text(json.dumps({"schema_version": "2.0", "name": "node-cli-fixture"}), encoding="utf-8")
118
+ result = subprocess.run(["node", str(NODE_CLI), "memory", "init", "--project-root", str(root), "--json"], cwd=ROOT, capture_output=True, text=True, check=False)
119
+ assert result.returncode == 0, result.stderr
120
+ recover = subprocess.run(["node", str(NODE_CLI), "memory", "recover", "--project-root", str(root)], cwd=ROOT, capture_output=True, text=True, check=False)
121
+ assert recover.returncode == 0, recover.stderr
122
+ assert json.loads(recover.stdout)["project"]["name"] == "node-cli-fixture"
123
+
124
+
125
+ if __name__ == "__main__":
126
+ test_memory_recovery_and_relocation()
127
+ test_cross_project_recovery_is_fail_closed()
128
+ test_node_cli_routes_memory_without_shell()
129
+ print("project memory contract tests: PASS")