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,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Evaluate MotionLoom's project-aware analyzer against a labeled project corpus.
|
|
3
|
+
|
|
4
|
+
The manifest records provenance and expected signals, but this runner never
|
|
5
|
+
clones, installs or executes code from external projects. A checkout is only
|
|
6
|
+
considered available when the caller explicitly places it under --workspace.
|
|
7
|
+
Missing external projects produce ``insufficient_evidence`` rather than a
|
|
8
|
+
false pass. This keeps product-value claims separate from owned fixtures.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
22
|
+
DEFAULT_MANIFEST = ROOT / "tests/evals/project-corpus.json"
|
|
23
|
+
ANALYZER = ROOT / "scripts/analyze.py"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def invoke(project: Path, output: Path, args: argparse.Namespace) -> subprocess.CompletedProcess[str]:
|
|
27
|
+
command = [
|
|
28
|
+
sys.executable,
|
|
29
|
+
str(ANALYZER),
|
|
30
|
+
str(project),
|
|
31
|
+
"--output",
|
|
32
|
+
str(output),
|
|
33
|
+
"--max-files",
|
|
34
|
+
str(args.max_files),
|
|
35
|
+
"--max-bytes",
|
|
36
|
+
str(args.max_bytes),
|
|
37
|
+
"--max-seconds",
|
|
38
|
+
str(args.max_seconds),
|
|
39
|
+
]
|
|
40
|
+
for value in args.ignore_dir:
|
|
41
|
+
command.extend(["--ignore-dir", value])
|
|
42
|
+
for value in args.ignore_glob:
|
|
43
|
+
command.extend(["--ignore-glob", value])
|
|
44
|
+
return subprocess.run(command, cwd=ROOT, capture_output=True, text=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def evaluate_case(case: dict, workspace: Path, repository_root: Path, args: argparse.Namespace, temp: Path) -> dict:
|
|
48
|
+
relative = Path(str(case["local_path"]))
|
|
49
|
+
base = repository_root if case.get("scope") == "repository" else workspace
|
|
50
|
+
project = (base / relative).resolve()
|
|
51
|
+
try:
|
|
52
|
+
project.relative_to(base.resolve())
|
|
53
|
+
except ValueError:
|
|
54
|
+
return {"id": case["id"], "class": case.get("class"), "status": "fail", "detail": "local_path escapes evaluation root"}
|
|
55
|
+
if not project.is_dir():
|
|
56
|
+
return {
|
|
57
|
+
"id": case["id"],
|
|
58
|
+
"class": case.get("class"),
|
|
59
|
+
"status": "unavailable",
|
|
60
|
+
"external": bool(case.get("external")),
|
|
61
|
+
"source": case.get("source"),
|
|
62
|
+
"detail": f"checkout not present at {relative.as_posix()}",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
output = temp / f"{case['id']}.json"
|
|
66
|
+
result = invoke(project, output, args)
|
|
67
|
+
if result.returncode != 0 or not output.is_file():
|
|
68
|
+
return {
|
|
69
|
+
"id": case["id"],
|
|
70
|
+
"class": case.get("class"),
|
|
71
|
+
"status": "fail",
|
|
72
|
+
"external": bool(case.get("external")),
|
|
73
|
+
"source": case.get("source"),
|
|
74
|
+
"detail": (result.stdout + result.stderr).strip()[-1000:],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
context = json.loads(output.read_text(encoding="utf-8"))
|
|
78
|
+
expected = case.get("expected", {})
|
|
79
|
+
checks = []
|
|
80
|
+
if expected.get("name") is not None:
|
|
81
|
+
checks.append((context.get("name") == expected["name"], f"name={context.get('name')!r}"))
|
|
82
|
+
if expected.get("framework") is not None:
|
|
83
|
+
actual = context.get("stack", {}).get("framework")
|
|
84
|
+
checks.append((actual == expected["framework"], f"framework={actual!r}"))
|
|
85
|
+
if expected.get("scan_truncated") is not None:
|
|
86
|
+
checks.append((context.get("scan_truncated") is expected["scan_truncated"], f"scan_truncated={context.get('scan_truncated')!r}"))
|
|
87
|
+
passed = all(item[0] for item in checks)
|
|
88
|
+
return {
|
|
89
|
+
"id": case["id"],
|
|
90
|
+
"class": case.get("class"),
|
|
91
|
+
"status": "pass" if passed else "fail",
|
|
92
|
+
"external": bool(case.get("external")),
|
|
93
|
+
"source": case.get("source"),
|
|
94
|
+
"checks": [detail for _, detail in checks],
|
|
95
|
+
"scan": context.get("scan", {}),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main() -> int:
|
|
100
|
+
parser = argparse.ArgumentParser(description="Run a provenance-labeled MotionLoom project corpus evaluation.")
|
|
101
|
+
parser.add_argument("--manifest", default=str(DEFAULT_MANIFEST))
|
|
102
|
+
parser.add_argument("--workspace", default=str(ROOT), help="Explicit directory containing corpus checkouts")
|
|
103
|
+
parser.add_argument("--repository-root", default=str(ROOT), help="First-party repository root")
|
|
104
|
+
parser.add_argument("--output")
|
|
105
|
+
parser.add_argument("--require-external", type=int, help="Override manifest external-project requirement")
|
|
106
|
+
parser.add_argument("--allow-insufficient", action="store_true", help="Return success while reporting insufficient_evidence")
|
|
107
|
+
parser.add_argument("--max-files", type=int, default=2500)
|
|
108
|
+
parser.add_argument("--max-bytes", type=int, default=25_000_000)
|
|
109
|
+
parser.add_argument("--max-seconds", type=float, default=10.0)
|
|
110
|
+
parser.add_argument("--ignore-dir", action="append", default=[])
|
|
111
|
+
parser.add_argument("--ignore-glob", action="append", default=[])
|
|
112
|
+
args = parser.parse_args()
|
|
113
|
+
|
|
114
|
+
manifest_path = Path(args.manifest).expanduser().resolve()
|
|
115
|
+
workspace = Path(args.workspace).expanduser().resolve()
|
|
116
|
+
repository_root = Path(args.repository_root).expanduser().resolve()
|
|
117
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
118
|
+
cases = manifest.get("projects", [])
|
|
119
|
+
required_external = args.require_external if args.require_external is not None else int(manifest.get("required_external_projects", 0))
|
|
120
|
+
|
|
121
|
+
with tempfile.TemporaryDirectory(prefix="motionloom-project-eval-") as td:
|
|
122
|
+
results = [evaluate_case(case, workspace, repository_root, args, Path(td)) for case in cases]
|
|
123
|
+
available_external = sum(1 for item in results if item.get("external") and item.get("status") == "pass")
|
|
124
|
+
failed = [item for item in results if item["status"] == "fail"]
|
|
125
|
+
unavailable_external = [item for item in results if item.get("external") and item["status"] == "unavailable"]
|
|
126
|
+
if failed:
|
|
127
|
+
status = "fail"
|
|
128
|
+
elif available_external < required_external:
|
|
129
|
+
status = "insufficient_evidence"
|
|
130
|
+
else:
|
|
131
|
+
status = "pass"
|
|
132
|
+
|
|
133
|
+
report = {
|
|
134
|
+
"schema_version": "1.0",
|
|
135
|
+
"corpus_id": manifest.get("corpus_id"),
|
|
136
|
+
"status": status,
|
|
137
|
+
"workspace": str(workspace),
|
|
138
|
+
"required_external_projects": required_external,
|
|
139
|
+
"available_external_projects": available_external,
|
|
140
|
+
"unavailable_external_projects": len(unavailable_external),
|
|
141
|
+
"project_count": len(results),
|
|
142
|
+
"results": results,
|
|
143
|
+
}
|
|
144
|
+
payload = json.dumps(report, indent=2, ensure_ascii=False) + "\n"
|
|
145
|
+
if args.output:
|
|
146
|
+
output = Path(args.output).expanduser().resolve()
|
|
147
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
output.write_text(payload, encoding="utf-8")
|
|
149
|
+
print(payload, end="")
|
|
150
|
+
if status == "insufficient_evidence" and args.allow_insufficient:
|
|
151
|
+
return 0
|
|
152
|
+
return 0 if status == "pass" else 1
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
raise SystemExit(main())
|
package/scripts/intelligence.py
CHANGED
|
@@ -824,11 +824,54 @@ def semantic_lint_validate_data(report: dict[str, Any]) -> list[str]:
|
|
|
824
824
|
issues.append("schema_version must be 0.1")
|
|
825
825
|
if report.get("status") not in {"pass", "warn", "fail"}:
|
|
826
826
|
issues.append("status must be pass, warn or fail")
|
|
827
|
-
|
|
828
|
-
|
|
827
|
+
for field in ("report_id", "task_id", "scene"):
|
|
828
|
+
if not isinstance(report.get(field), str) or not report.get(field).strip():
|
|
829
|
+
issues.append(f"{field} must be a non-empty string")
|
|
830
|
+
ruleset = report.get("ruleset")
|
|
831
|
+
if not isinstance(ruleset, dict) or not isinstance(ruleset.get("id"), str) or not ruleset.get("id").strip() or not isinstance(ruleset.get("version"), str) or not ruleset.get("version").strip():
|
|
832
|
+
issues.append("ruleset must contain non-empty id and version")
|
|
833
|
+
summary = report.get("summary") if isinstance(report.get("summary"), dict) else {}
|
|
834
|
+
summary_required = {"total", "errors", "warnings", "infos", "blocking"}
|
|
835
|
+
issues.extend(f"summary missing {field}" for field in sorted(summary_required - set(summary)))
|
|
836
|
+
findings = report.get("findings") if isinstance(report.get("findings"), list) else []
|
|
837
|
+
if not isinstance(report.get("findings"), list):
|
|
838
|
+
issues.append("findings must be an array")
|
|
829
839
|
if summary.get("total") != len(findings):
|
|
830
840
|
issues.append("summary.total does not match findings length")
|
|
831
|
-
|
|
841
|
+
finding_required = {"id", "rule_id", "category", "severity", "confidence", "basis", "message", "evidence_refs", "affected_paths", "approval_blocking"}
|
|
842
|
+
categories = {"intent", "timing", "easing", "accessibility", "performance", "continuity", "anti_pattern"}
|
|
843
|
+
severities = {"info", "warning", "error"}
|
|
844
|
+
bases = {"deterministic", "runtime", "human", "heuristic"}
|
|
845
|
+
severity_counts = {"info": 0, "warning": 0, "error": 0}
|
|
846
|
+
blocking = 0
|
|
847
|
+
for index, item in enumerate(findings):
|
|
848
|
+
if not isinstance(item, dict):
|
|
849
|
+
issues.append(f"finding[{index}] must be an object")
|
|
850
|
+
continue
|
|
851
|
+
issues.extend(f"finding[{index}] missing {field}" for field in sorted(finding_required - set(item)))
|
|
852
|
+
if item.get("severity") not in severities:
|
|
853
|
+
issues.append(f"finding[{index}] severity is invalid")
|
|
854
|
+
else:
|
|
855
|
+
severity_counts[item["severity"]] += 1
|
|
856
|
+
if item.get("category") not in categories:
|
|
857
|
+
issues.append(f"finding[{index}] category is invalid")
|
|
858
|
+
if item.get("basis") not in bases:
|
|
859
|
+
issues.append(f"finding[{index}] basis is invalid")
|
|
860
|
+
if not isinstance(item.get("confidence"), (int, float)) or not 0 <= item.get("confidence", -1) <= 1:
|
|
861
|
+
issues.append(f"finding[{index}] confidence must be between 0 and 1")
|
|
862
|
+
if not isinstance(item.get("evidence_refs"), list) or not isinstance(item.get("affected_paths"), list):
|
|
863
|
+
issues.append(f"finding[{index}] evidence_refs and affected_paths must be arrays")
|
|
864
|
+
if item.get("approval_blocking") is True:
|
|
865
|
+
blocking += 1
|
|
866
|
+
for field in ("total", "errors", "warnings", "infos", "blocking"):
|
|
867
|
+
if not isinstance(summary.get(field), int) or summary.get(field) < 0:
|
|
868
|
+
issues.append(f"summary.{field} must be a non-negative integer")
|
|
869
|
+
if summary.get("errors") != severity_counts["error"]:
|
|
870
|
+
issues.append("summary.errors does not match findings")
|
|
871
|
+
if summary.get("warnings") != severity_counts["warning"]:
|
|
872
|
+
issues.append("summary.warnings does not match findings")
|
|
873
|
+
if summary.get("infos") != severity_counts["info"]:
|
|
874
|
+
issues.append("summary.infos does not match findings")
|
|
832
875
|
if summary.get("blocking") != blocking:
|
|
833
876
|
issues.append("summary.blocking does not match findings")
|
|
834
877
|
if report.get("status") == "fail" and blocking == 0:
|
|
@@ -1040,15 +1083,72 @@ def continuity_report_data(task_dirs: list[Path], project_id: str | None = None)
|
|
|
1040
1083
|
|
|
1041
1084
|
def continuity_validate_data(report: dict[str, Any]) -> list[str]:
|
|
1042
1085
|
issues: list[str] = []
|
|
1086
|
+
required = {"schema_version", "report_id", "project_id", "status", "scenes", "transitions", "summary", "generated_at"}
|
|
1087
|
+
issues.extend(f"missing {field}" for field in sorted(required - set(report)))
|
|
1043
1088
|
if report.get("schema_version") != "0.1":
|
|
1044
1089
|
issues.append("schema_version must be 0.1")
|
|
1045
|
-
|
|
1046
|
-
|
|
1090
|
+
if report.get("status") not in {"pass", "warn", "fail"}:
|
|
1091
|
+
issues.append("status must be pass, warn or fail")
|
|
1092
|
+
for field in ("report_id", "project_id"):
|
|
1093
|
+
if not isinstance(report.get(field), str) or not report.get(field).strip():
|
|
1094
|
+
issues.append(f"{field} must be a non-empty string")
|
|
1095
|
+
scenes = report.get("scenes") if isinstance(report.get("scenes"), list) else []
|
|
1096
|
+
transitions = report.get("transitions") if isinstance(report.get("transitions"), list) else []
|
|
1097
|
+
if not isinstance(report.get("scenes"), list):
|
|
1098
|
+
issues.append("scenes must be an array")
|
|
1099
|
+
if not isinstance(report.get("transitions"), list):
|
|
1100
|
+
issues.append("transitions must be an array")
|
|
1047
1101
|
if not scenes:
|
|
1048
1102
|
issues.append("continuity report requires at least one scene")
|
|
1049
1103
|
if len(transitions) != max(0, len(scenes) - 1):
|
|
1050
1104
|
issues.append("transition count must equal scene count minus one")
|
|
1051
|
-
|
|
1105
|
+
scene_names = set()
|
|
1106
|
+
for index, scene in enumerate(scenes):
|
|
1107
|
+
if not isinstance(scene, dict):
|
|
1108
|
+
issues.append(f"scene[{index}] must be an object")
|
|
1109
|
+
continue
|
|
1110
|
+
scene_required = {"scene", "order", "motion_ir_path", "motion_ir_sha256", "context_hash", "intent"}
|
|
1111
|
+
issues.extend(f"scene[{index}] missing {field}" for field in sorted(scene_required - set(scene)))
|
|
1112
|
+
name = scene.get("scene")
|
|
1113
|
+
if not isinstance(name, str) or not name.strip():
|
|
1114
|
+
issues.append(f"scene[{index}].scene must be a non-empty string")
|
|
1115
|
+
elif name in scene_names:
|
|
1116
|
+
issues.append(f"duplicate scene name: {name}")
|
|
1117
|
+
else:
|
|
1118
|
+
scene_names.add(name)
|
|
1119
|
+
if not isinstance(scene.get("order"), int) or scene.get("order", -1) < 0:
|
|
1120
|
+
issues.append(f"scene[{index}].order must be a non-negative integer")
|
|
1121
|
+
for field in ("motion_ir_path", "intent"):
|
|
1122
|
+
if not isinstance(scene.get(field), str) or not scene.get(field).strip():
|
|
1123
|
+
issues.append(f"scene[{index}].{field} must be a non-empty string")
|
|
1124
|
+
for field in ("motion_ir_sha256", "context_hash"):
|
|
1125
|
+
value = scene.get(field)
|
|
1126
|
+
if not isinstance(value, str) or len(value) != 64 or any(char not in "0123456789abcdef" for char in value):
|
|
1127
|
+
issues.append(f"scene[{index}].{field} must be a lowercase SHA-256 digest")
|
|
1128
|
+
for index, transition in enumerate(transitions):
|
|
1129
|
+
if not isinstance(transition, dict):
|
|
1130
|
+
issues.append(f"transition[{index}] must be an object")
|
|
1131
|
+
continue
|
|
1132
|
+
transition_required = {"id", "from_scene", "to_scene", "status", "checks"}
|
|
1133
|
+
issues.extend(f"transition[{index}] missing {field}" for field in sorted(transition_required - set(transition)))
|
|
1134
|
+
if transition.get("status") not in {"pass", "warn", "fail"}:
|
|
1135
|
+
issues.append(f"transition[{index}].status is invalid")
|
|
1136
|
+
if transition.get("from_scene") not in scene_names or transition.get("to_scene") not in scene_names:
|
|
1137
|
+
issues.append(f"transition[{index}] references unknown scene")
|
|
1138
|
+
if not isinstance(transition.get("checks"), list) or any(not isinstance(item, str) or not item.strip() for item in transition.get("checks", [])):
|
|
1139
|
+
issues.append(f"transition[{index}].checks must be a non-empty-string array")
|
|
1140
|
+
summary = report.get("summary") if isinstance(report.get("summary"), dict) else {}
|
|
1141
|
+
summary_required = {"scene_count", "transition_count", "errors", "warnings", "blocking"}
|
|
1142
|
+
issues.extend(f"summary missing {field}" for field in sorted(summary_required - set(summary)))
|
|
1143
|
+
if report.get("summary") is not None and not isinstance(report.get("summary"), dict):
|
|
1144
|
+
issues.append("summary must be an object")
|
|
1145
|
+
for field in ("scene_count", "transition_count", "errors", "warnings", "blocking"):
|
|
1146
|
+
minimum = 1 if field == "scene_count" else 0
|
|
1147
|
+
if not isinstance(summary.get(field), int) or summary.get(field) < minimum:
|
|
1148
|
+
issues.append(f"summary.{field} must be a valid non-negative count")
|
|
1149
|
+
if summary.get("transition_count") != len(transitions):
|
|
1150
|
+
issues.append("summary.transition_count does not match transitions")
|
|
1151
|
+
if summary.get("scene_count") != len(scenes):
|
|
1052
1152
|
issues.append("summary.scene_count does not match scenes")
|
|
1053
1153
|
return issues
|
|
1054
1154
|
|
package/scripts/pr.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
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
|
+
"--require-visual-truth",
|
|
98
|
+
]
|
|
99
|
+
print("== running context-bound quality gate ==")
|
|
100
|
+
run(repo, [python, *quality_args])
|
|
101
|
+
run(repo, [python, str(repo / "scripts" / "review-hook.py"), "validate", "--task-dir", str(task_dir), "--require-approved"])
|
|
102
|
+
run(repo, [python, str(repo / "scripts" / "report.py"), "check", "--task-dir", str(task_dir)])
|
|
103
|
+
|
|
104
|
+
branch = f"fix/{args.scene}"
|
|
105
|
+
try:
|
|
106
|
+
run(repo, ["git", "checkout", "-b", branch])
|
|
107
|
+
except subprocess.CalledProcessError:
|
|
108
|
+
run(repo, ["git", "checkout", branch])
|
|
109
|
+
|
|
110
|
+
task_rel = task_dir.relative_to(repo)
|
|
111
|
+
run(repo, ["git", "add", str(Path("src") / "output" / args.scene), str(task_rel)])
|
|
112
|
+
staged = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=repo).returncode
|
|
113
|
+
if staged == 0:
|
|
114
|
+
print("error: no staged scene changes to commit", file=sys.stderr)
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
title = args.title or f"animation: scene '{args.scene}' (verified in Dev Lab)"
|
|
118
|
+
commit_message = (
|
|
119
|
+
f"feat(animation): scene '{args.scene}' — proven in Dev Lab\n\n"
|
|
120
|
+
f"- motion-spec signed (see src/output/{args.scene}/motion-spec.json)\n"
|
|
121
|
+
f"- snapshot frames: 0/50/100% in src/output/{args.scene}/snapshot/\n"
|
|
122
|
+
"- context-bound quality gate: passed\n"
|
|
123
|
+
f"- brand tokens bound from {args.context or 'project-context.json'}"
|
|
124
|
+
)
|
|
125
|
+
run(repo, ["git", "commit", "-m", commit_message])
|
|
126
|
+
|
|
127
|
+
if not args.open_pr and os.environ.get("OPEN_PR") != "1":
|
|
128
|
+
print(f"== committed to {branch} — OPEN_PR=0, push/open PR manually ==")
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
if shutil.which("gh") is None:
|
|
132
|
+
print(f"== committed to {branch} — install gh CLI to open the PR ==")
|
|
133
|
+
print(f" git push origin {branch}")
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
run(repo, ["git", "push", "-u", "origin", branch])
|
|
137
|
+
body = (
|
|
138
|
+
f"## Scene: {args.scene}\n\n"
|
|
139
|
+
"Verified in the Dev Lab (checklist + snapshot diffs attached).\n"
|
|
140
|
+
"Framework, duration, easing, reduced-motion policy and theme tokens per the signed motion spec.\n\n"
|
|
141
|
+
"### Snapshots\n| 0% | 50% | 100% |\n|---|---|---|\n"
|
|
142
|
+
"| `snapshot/frame-00.png` | `snapshot/frame-50.png` | `snapshot/frame-100.png` |\n\n"
|
|
143
|
+
"Ready to review — comment fixes in the Dev Lab or approve to merge."
|
|
144
|
+
)
|
|
145
|
+
run(repo, ["gh", "pr", "create", "--title", title, "--body", body])
|
|
146
|
+
print(f"== PR opened for scene: {args.scene} ==")
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
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);
|