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.
- package/.agents/skills/motionloom/SKILL.md +14 -0
- package/.claude/skills/motionloom.md +5 -0
- package/.codex/skills/motionloom.md +11 -0
- package/AGENTS.md +17 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +19 -0
- package/CONTRIBUTING.md +65 -0
- package/README.md +193 -134
- package/ROADMAP.md +36 -0
- package/SECURITY.md +28 -0
- package/SKILL.md +57 -9
- package/SUPPORT.md +23 -0
- package/agent-card.json +42 -6
- package/agent-surfaces.json +79 -0
- package/bin/motionloom.mjs +33 -5
- package/docs/AGENT-INTEGRATION.md +47 -0
- package/docs/CHECKLIST.md +2 -1
- package/docs/STATUS.md +33 -0
- package/docs/audits/2.1.0-deep-stress-evaluation.md +97 -0
- package/docs/audits/ci-replay-remediation-2026-08-13.md +33 -0
- package/docs/audits/data/2.1.0-deep-stress-6900.json +329 -0
- package/docs/audits/data/deep-stress-latest.json +329 -0
- package/docs/audits/external-project-corpus-2026-08-13.md +26 -0
- package/docs/releases/2.1.0.md +23 -0
- package/docs/releases/2.2.0.md +35 -0
- package/docs/releases/npm-publish-from-workstation.md +6 -6
- package/examples/agent-consumer/README.md +18 -0
- package/examples/agent-consumer/fixture-manifest.json +82 -0
- package/package.json +69 -28
- package/references/agent-interoperability.md +29 -0
- package/references/intelligence-core.md +5 -1
- package/schemas/agent-surfaces.schema.json +78 -0
- package/schemas/project-memory.schema.json +180 -0
- package/schemas/remediation-history.schema.json +23 -0
- package/schemas/scene-manifest.schema.json +1 -0
- package/schemas/visual-truth.schema.json +80 -0
- package/scripts/analyze.py +56 -0
- package/scripts/capture-runtime-telemetry.py +119 -0
- package/scripts/devlab.py +126 -0
- package/scripts/discovery.py +257 -0
- package/scripts/docs-audit.py +112 -0
- package/scripts/eval-intelligence.py +23 -0
- package/scripts/eval-projects.py +156 -0
- package/scripts/intelligence.py +106 -6
- package/scripts/pr.py +151 -0
- package/scripts/prepack-clean.mjs +37 -0
- package/scripts/project-memory.py +483 -0
- package/scripts/project_memory_loader.py +31 -0
- package/scripts/quality-gate.py +43 -3
- package/scripts/release-verify.py +52 -0
- package/scripts/remediation-learning.py +326 -0
- package/scripts/render.py +65 -0
- package/scripts/report.py +60 -2
- package/scripts/review-hook.py +13 -2
- package/scripts/skill-doctor.py +12 -2
- package/scripts/to-dotlottie.mjs +26 -20
- package/scripts/visual-truth.py +310 -0
- package/src/core/analyzer.py +174 -25
- package/src/output/browser-review-smoke/manifest.json +1 -0
- package/src/output/browser-review-smoke/visual-truth.json +68 -0
- package/tests/evals/intelligence-cases.json +10 -0
- package/tests/evals/project-corpus.json +51 -0
- package/tests/scripts/run_tests.py +111 -1
- 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"]}
|
package/scripts/quality-gate.py
CHANGED
|
@@ -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
|
-
|
|
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())
|