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
@@ -4,6 +4,7 @@
4
4
  "framework": "lottie",
5
5
  "category": "loading",
6
6
  "file": "animation.json",
7
+ "visual_truth": "visual-truth.json",
7
8
  "source_binding": {
8
9
  "kind": "fixture",
9
10
  "source_path": "animation.json",
@@ -0,0 +1,68 @@
1
+ {
2
+ "schema_version": "1.0",
3
+ "contract": "motionloom-visual-truth",
4
+ "generated_at": "2026-08-13T13:53:38.679496Z",
5
+ "status": "review_required",
6
+ "scene": "browser-review-smoke",
7
+ "task_id": "browser-review-smoke-task",
8
+ "frames": {
9
+ "baseline": {
10
+ "role": "baseline",
11
+ "percent": 100,
12
+ "path": "src/output/browser-review-smoke/snapshot/frame-00.png",
13
+ "sha256": "8a3bf18101d395ee00a6c6899c31910fbd2c405aee7e83143ce08832e740c5da",
14
+ "bytes": 3588,
15
+ "width": 512,
16
+ "height": 512
17
+ },
18
+ "candidate": {
19
+ "role": "candidate",
20
+ "percent": 100,
21
+ "path": "src/output/browser-review-smoke/snapshot/frame-100.png",
22
+ "sha256": "b3dac3308b0b7295ee3d5d4d87225017692b4d1f6d46828b482948cdd8979e2e",
23
+ "bytes": 4228,
24
+ "width": 512,
25
+ "height": 512
26
+ }
27
+ },
28
+ "comparison": {
29
+ "metric": "sha256-image-identity",
30
+ "method": "PNG metadata plus byte identity; no visual approval inferred",
31
+ "same_dimensions": true,
32
+ "byte_identical": false,
33
+ "changed": true,
34
+ "distance": 1.0,
35
+ "threshold": 0.0,
36
+ "interpretation": "review_required"
37
+ },
38
+ "regions": [
39
+ {
40
+ "id": "full-frame",
41
+ "label": "Full rendered frame",
42
+ "reason": "Baseline and candidate PNG identities differ; inspect the exact candidate in Dev Lab.",
43
+ "severity": "review",
44
+ "evidence": [
45
+ "src/output/browser-review-smoke/snapshot/frame-00.png",
46
+ "src/output/browser-review-smoke/snapshot/frame-100.png"
47
+ ]
48
+ }
49
+ ],
50
+ "provenance": {
51
+ "source_path": "src/output/browser-review-smoke/animation.json",
52
+ "source_sha256": "c5ab312427678b17ae903d280d89db9d202670f031f10ad6fe774fc9aaecee8a",
53
+ "manifest_path": "src/output/browser-review-smoke/manifest.json",
54
+ "manifest_sha256": "467fea255a18efb8687e78a1a60662ad30d4fa6f77d9c3f3878196f3c5e670ad",
55
+ "runtime_evidence_path": "artifacts/browser-review-smoke-task/runtime-adapters/runtime-evidence.json",
56
+ "runtime_evidence_sha256": "2d51a37dbd1962c3f8539fed53fd5709448da55f2b00f2ceb37dfd8a55d0d716",
57
+ "motion_ir_path": "artifacts/browser-review-smoke-task/motion-ir.json",
58
+ "motion_ir_sha256": "bdb2305737e5dee9b9ee078dd5657a4a5717bf4184f112a1d01ac82361c5a23f",
59
+ "runtime_status": "pass",
60
+ "motion_ir_schema_version": "0.1"
61
+ },
62
+ "review_boundary": {
63
+ "approval": false,
64
+ "user_review_required": true,
65
+ "decision": "pending",
66
+ "pr_side_effects": "explicit-confirmation"
67
+ }
68
+ }
@@ -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([
@@ -801,6 +840,18 @@ def test_observability_contract():
801
840
  ], capture_output=True, text=True)
802
841
  check("review rejects foreign task candidate", foreign_review.returncode != 0 and "task_id" in foreign_review.stderr)
803
842
 
843
+
844
+ def test_quality_workflow_rebuilds_replay_after_generated_artifacts():
845
+ workflow = (ROOT / ".github/workflows/quality.yml").read_text(encoding="utf-8")
846
+ capture = workflow.find("python3 scripts/intelligence.py replay capture")
847
+ render = workflow.find("- name: Render runtime snapshot frames for changed scenes")
848
+ gate = workflow.find("- name: Enforce context-bound quality gate")
849
+ check("quality workflow rebuilds replay bundle", capture >= 0)
850
+ check("replay rebuild follows runtime rendering", render >= 0 and render < capture)
851
+ check("replay rebuild precedes quality gate", gate >= 0 and capture < gate)
852
+ check("replay rebuild binds task directory and root", '--task-dir "$task_dir" --root .' in workflow)
853
+ check("replay rebuild writes canonical task bundle path", '--output "$task_dir/replay-bundle.json"' in workflow)
854
+
804
855
  doctor = subprocess.run([sys.executable, str(ROOT / "scripts/skill-doctor.py"), "--json"], capture_output=True, text=True)
805
856
  doctor_data = json.loads(doctor.stdout)
806
857
  check("skill doctor passes package structure", doctor.returncode == 0 and doctor_data.get("status") == "pass")
@@ -815,10 +866,68 @@ def test_observability_contract():
815
866
  attestation_tests.returncode == 0 and "attestation contract tests: PASS" in attestation_tests.stdout,
816
867
  )
817
868
 
869
+ memory_tests = subprocess.run(
870
+ [sys.executable, str(ROOT / "tests/scripts/test_project_memory.py")],
871
+ capture_output=True,
872
+ text=True,
873
+ )
874
+ check(
875
+ "project memory recovery and cross-platform contract passes",
876
+ memory_tests.returncode == 0 and "project memory contract tests: PASS" in memory_tests.stdout,
877
+ )
878
+ discovery_tests = subprocess.run(
879
+ [sys.executable, str(ROOT / "tests/scripts/test_discovery.py")],
880
+ capture_output=True,
881
+ text=True,
882
+ )
883
+ check(
884
+ "Agent discovery and installation contract passes",
885
+ discovery_tests.returncode == 0 and "discovery contract tests: PASS" in discovery_tests.stdout,
886
+ )
887
+ consumer_tests = subprocess.run(
888
+ [sys.executable, str(ROOT / "tests/scripts/test_consumer_fixtures.py")],
889
+ capture_output=True,
890
+ text=True,
891
+ )
892
+ check(
893
+ "Agent consumer fixture contract passes",
894
+ consumer_tests.returncode == 0 and "consumer fixture tests: PASS" in consumer_tests.stdout,
895
+ )
896
+ matrix_tests = subprocess.run(
897
+ [sys.executable, str(ROOT / "tests/scripts/test_installation_matrix.py")],
898
+ capture_output=True,
899
+ text=True,
900
+ )
901
+ check(
902
+ "cross-platform installation matrix contract passes",
903
+ matrix_tests.returncode == 0 and "installation matrix tests: PASS" in matrix_tests.stdout,
904
+ )
905
+ visual_tests = subprocess.run(
906
+ [sys.executable, str(ROOT / "tests/scripts/test_visual_truth.py")],
907
+ capture_output=True,
908
+ text=True,
909
+ )
910
+ check(
911
+ "Visual Truth contract preserves provenance and review boundary",
912
+ visual_tests.returncode == 0 and "visual truth contract tests: PASS" in visual_tests.stdout,
913
+ )
914
+ remediation_tests = subprocess.run(
915
+ [sys.executable, str(ROOT / "tests/scripts/test_remediation_learning.py")],
916
+ capture_output=True,
917
+ text=True,
918
+ )
919
+ check(
920
+ "Remediation Learning preserves append-only history and first-pass metrics",
921
+ remediation_tests.returncode == 0 and "remediation learning tests: PASS" in remediation_tests.stdout,
922
+ remediation_tests.stdout.strip() or remediation_tests.stderr.strip(),
923
+ )
924
+
818
925
 
819
926
  if __name__ == "__main__":
820
927
  print("== MotionLoom engine tests ==")
821
928
  test_analyzer_on_fixture()
929
+ test_analyzer_scan_budget_and_ignore_rules()
930
+ test_project_corpus_harness_is_explicit_about_evidence()
822
931
  test_spec_generate_and_validate()
823
932
  test_rig_build_and_pose()
824
933
  test_lottie_scaffold_valid()
@@ -836,6 +945,7 @@ if __name__ == "__main__":
836
945
  sys.path.insert(0, str(ROOT))
837
946
  test_category_coverage()
838
947
  test_observability_contract()
948
+ test_quality_workflow_rebuilds_replay_after_generated_artifacts()
839
949
  print()
840
950
  if FAILED:
841
951
  print(f"{len(FAILED)} test(s) FAILED: {', '.join(FAILED)}")
@@ -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")