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,326 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Record and summarize user-confirmed remediation and benchmark history.
|
|
3
|
+
|
|
4
|
+
The history is an append-only JSONL ledger. Each event carries a hash of its
|
|
5
|
+
canonical payload and the hash of the previous event. This makes the ledger
|
|
6
|
+
portable and inspectable without introducing a database or treating metrics as
|
|
7
|
+
approval. Only explicitly user-confirmed remediation outcomes contribute to
|
|
8
|
+
acceptance metrics.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
26
|
+
SCHEMA_VERSION = "0.1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def now() -> str:
|
|
30
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def canonical(value: Any) -> bytes:
|
|
34
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sha256_bytes(value: bytes) -> str:
|
|
38
|
+
return hashlib.sha256(value).hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def event_digest(event: dict[str, Any]) -> str:
|
|
42
|
+
payload = {key: value for key, value in event.items() if key != "event_sha256"}
|
|
43
|
+
return sha256_bytes(canonical(payload))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def history_path(raw: str | None) -> Path:
|
|
47
|
+
return (Path(raw).expanduser() if raw else ROOT / "artifacts" / "remediation-history.jsonl").resolve()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def output_path(raw: str | None, default_name: str) -> Path:
|
|
51
|
+
return (Path(raw).expanduser() if raw else ROOT / "artifacts" / default_name).resolve()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_history(path: Path) -> tuple[list[dict[str, Any]], list[str]]:
|
|
55
|
+
if not path.exists():
|
|
56
|
+
return [], []
|
|
57
|
+
if path.is_symlink():
|
|
58
|
+
return [], ["history path must not be a symlink"]
|
|
59
|
+
events: list[dict[str, Any]] = []
|
|
60
|
+
errors: list[str] = []
|
|
61
|
+
previous: str | None = None
|
|
62
|
+
seen: set[str] = set()
|
|
63
|
+
try:
|
|
64
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
65
|
+
except (OSError, UnicodeError) as error:
|
|
66
|
+
return [], [f"history read failed: {error}"]
|
|
67
|
+
for line_number, line in enumerate(lines, start=1):
|
|
68
|
+
if not line.strip():
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
event = json.loads(line)
|
|
72
|
+
except json.JSONDecodeError as error:
|
|
73
|
+
errors.append(f"line {line_number}: invalid JSON: {error.msg}")
|
|
74
|
+
continue
|
|
75
|
+
if not isinstance(event, dict):
|
|
76
|
+
errors.append(f"line {line_number}: event must be an object")
|
|
77
|
+
continue
|
|
78
|
+
event_id = str(event.get("event_id", ""))
|
|
79
|
+
if not event_id:
|
|
80
|
+
errors.append(f"line {line_number}: event_id is required")
|
|
81
|
+
if event_id in seen:
|
|
82
|
+
errors.append(f"line {line_number}: duplicate event_id {event_id}")
|
|
83
|
+
seen.add(event_id)
|
|
84
|
+
if event.get("schema_version") != SCHEMA_VERSION:
|
|
85
|
+
errors.append(f"line {line_number}: unsupported schema_version")
|
|
86
|
+
if event.get("previous_event_sha256") != previous:
|
|
87
|
+
errors.append(f"line {line_number}: previous_event_sha256 does not match ledger head")
|
|
88
|
+
expected = event_digest(event)
|
|
89
|
+
if event.get("event_sha256") != expected:
|
|
90
|
+
errors.append(f"line {line_number}: event_sha256 mismatch")
|
|
91
|
+
previous = expected
|
|
92
|
+
events.append(event)
|
|
93
|
+
return events, errors
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def append_event(path: Path, event: dict[str, Any]) -> dict[str, Any]:
|
|
97
|
+
events, errors = read_history(path)
|
|
98
|
+
if errors:
|
|
99
|
+
raise ValueError("cannot append to invalid history: " + "; ".join(errors))
|
|
100
|
+
event = dict(event)
|
|
101
|
+
event.setdefault("schema_version", SCHEMA_VERSION)
|
|
102
|
+
event.setdefault("recorded_at", now())
|
|
103
|
+
event["previous_event_sha256"] = events[-1].get("event_sha256") if events else None
|
|
104
|
+
event["event_sha256"] = event_digest(event)
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
if path.exists() and path.is_symlink():
|
|
107
|
+
raise ValueError("history path must not be a symlink")
|
|
108
|
+
with path.open("a", encoding="utf-8", newline="\n") as handle:
|
|
109
|
+
handle.write(json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n")
|
|
110
|
+
handle.flush()
|
|
111
|
+
os.fsync(handle.fileno())
|
|
112
|
+
return event
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def file_ref(raw: str | None) -> dict[str, Any] | None:
|
|
116
|
+
if not raw:
|
|
117
|
+
return None
|
|
118
|
+
path = Path(raw).expanduser().resolve()
|
|
119
|
+
result: dict[str, Any] = {"path": str(path)}
|
|
120
|
+
if path.is_file():
|
|
121
|
+
result["sha256"] = sha256_bytes(path.read_bytes())
|
|
122
|
+
result["bytes"] = path.stat().st_size
|
|
123
|
+
result["exists"] = True
|
|
124
|
+
else:
|
|
125
|
+
result["exists"] = False
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cmd_record_outcome(args: argparse.Namespace) -> int:
|
|
130
|
+
if args.correction_count < 0:
|
|
131
|
+
raise ValueError("correction-count must be >= 0")
|
|
132
|
+
event = append_event(history_path(args.history), {
|
|
133
|
+
"event_id": args.event_id,
|
|
134
|
+
"event_type": "remediation_outcome",
|
|
135
|
+
"issue_id": args.issue_id,
|
|
136
|
+
"issue_class": args.issue_class or args.issue_id.split(".", 1)[0],
|
|
137
|
+
"summary": args.summary,
|
|
138
|
+
"root_cause": args.root_cause or "",
|
|
139
|
+
"resolution": args.resolution or "",
|
|
140
|
+
"result": args.result,
|
|
141
|
+
"correction_count": args.correction_count,
|
|
142
|
+
"first_pass_accepted": args.result == "pass" and args.correction_count == 0,
|
|
143
|
+
"rerun_scope": args.rerun_scope or [],
|
|
144
|
+
"user_confirmed": True,
|
|
145
|
+
"source_task_id": args.source_task_id,
|
|
146
|
+
"evidence": [ref for raw in (args.evidence or []) if (ref := file_ref(raw))],
|
|
147
|
+
})
|
|
148
|
+
emit({"status": "recorded", "event": event}, args.json)
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cmd_record_benchmark(args: argparse.Namespace) -> int:
|
|
153
|
+
if args.iterations <= 0 or args.p95_ms < 0 or args.threshold_ms <= 0:
|
|
154
|
+
raise ValueError("benchmark iterations must be > 0, p95-ms must be >= 0 and threshold-ms must be > 0")
|
|
155
|
+
event = append_event(history_path(args.history), {
|
|
156
|
+
"event_id": args.event_id,
|
|
157
|
+
"event_type": "benchmark_run",
|
|
158
|
+
"operation": args.operation,
|
|
159
|
+
"task_id": args.task_id,
|
|
160
|
+
"scene": args.scene,
|
|
161
|
+
"iterations": args.iterations,
|
|
162
|
+
"p95_ms": args.p95_ms,
|
|
163
|
+
"threshold_ms": args.threshold_ms,
|
|
164
|
+
"status": args.status or ("pass" if args.p95_ms < args.threshold_ms else "fail"),
|
|
165
|
+
"provenance": file_ref(args.evidence),
|
|
166
|
+
})
|
|
167
|
+
emit({"status": "recorded", "event": event}, args.json)
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def percentile(values: list[int | float], fraction: float) -> int | float | None:
|
|
172
|
+
if not values:
|
|
173
|
+
return None
|
|
174
|
+
ordered = sorted(values)
|
|
175
|
+
index = max(0, math.ceil(len(ordered) * fraction) - 1)
|
|
176
|
+
return ordered[index]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def rate(numerator: int, denominator: int) -> float | None:
|
|
180
|
+
return round(numerator / denominator, 4) if denominator else None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def cmd_summary(args: argparse.Namespace) -> int:
|
|
184
|
+
path = history_path(args.history)
|
|
185
|
+
events, errors = read_history(path)
|
|
186
|
+
if errors:
|
|
187
|
+
emit({"status": "fail", "history": str(path), "errors": errors}, args.json)
|
|
188
|
+
return 1
|
|
189
|
+
outcomes = [event for event in events if event.get("event_type") == "remediation_outcome"]
|
|
190
|
+
confirmed = [event for event in outcomes if event.get("user_confirmed") is True]
|
|
191
|
+
benchmarks = [event for event in events if event.get("event_type") == "benchmark_run"]
|
|
192
|
+
passes = [event for event in confirmed if event.get("result") == "pass"]
|
|
193
|
+
first_passes = [event for event in confirmed if event.get("first_pass_accepted") is True]
|
|
194
|
+
corrections = [int(event.get("correction_count", 0)) for event in confirmed]
|
|
195
|
+
p95_corrections = percentile(corrections, 0.95)
|
|
196
|
+
outlier_threshold = max(3, int(p95_corrections or 0))
|
|
197
|
+
outliers = [
|
|
198
|
+
{"event_id": event.get("event_id"), "issue_id": event.get("issue_id"), "correction_count": event.get("correction_count")}
|
|
199
|
+
for event in confirmed if int(event.get("correction_count", 0)) >= outlier_threshold and int(event.get("correction_count", 0)) > 0
|
|
200
|
+
]
|
|
201
|
+
by_issue_class: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
202
|
+
for event in confirmed:
|
|
203
|
+
by_issue_class[str(event.get("issue_class") or "unknown")].append(event)
|
|
204
|
+
issue_summary = {}
|
|
205
|
+
for issue_class, items in sorted(by_issue_class.items()):
|
|
206
|
+
issue_passes = sum(item.get("result") == "pass" for item in items)
|
|
207
|
+
issue_first_passes = sum(item.get("first_pass_accepted") is True for item in items)
|
|
208
|
+
issue_summary[issue_class] = {
|
|
209
|
+
"outcomes": len(items),
|
|
210
|
+
"passes": issue_passes,
|
|
211
|
+
"success_rate": rate(issue_passes, len(items)),
|
|
212
|
+
"first_pass_acceptance_rate": rate(issue_first_passes, len(items)),
|
|
213
|
+
"average_correction_count": round(sum(int(item.get("correction_count", 0)) for item in items) / len(items), 4),
|
|
214
|
+
}
|
|
215
|
+
benchmark_passes = sum(event.get("status") == "pass" for event in benchmarks)
|
|
216
|
+
summary = {
|
|
217
|
+
"schema_version": SCHEMA_VERSION,
|
|
218
|
+
"summary_id": f"remediation-summary-{path.stem}",
|
|
219
|
+
"status": "pass",
|
|
220
|
+
"history_path": str(path),
|
|
221
|
+
"history_sha256": sha256_bytes(path.read_bytes()) if path.is_file() else None,
|
|
222
|
+
"generated_at": now(),
|
|
223
|
+
"ledger": {"event_count": len(events), "outcomes": len(outcomes), "confirmed_outcomes": len(confirmed), "ignored_unconfirmed_outcomes": len(outcomes) - len(confirmed), "benchmarks": len(benchmarks)},
|
|
224
|
+
"remediation": {
|
|
225
|
+
"passes": len(passes),
|
|
226
|
+
"success_rate": rate(len(passes), len(confirmed)),
|
|
227
|
+
"first_pass_acceptances": len(first_passes),
|
|
228
|
+
"first_pass_acceptance_rate": rate(len(first_passes), len(confirmed)),
|
|
229
|
+
"average_correction_count": round(sum(corrections) / len(corrections), 4) if corrections else None,
|
|
230
|
+
"p95_correction_count": p95_corrections,
|
|
231
|
+
"outlier_threshold": outlier_threshold,
|
|
232
|
+
"outliers": outliers,
|
|
233
|
+
"by_issue_class": issue_summary,
|
|
234
|
+
},
|
|
235
|
+
"benchmarks_summary": {
|
|
236
|
+
"runs": len(benchmarks),
|
|
237
|
+
"passes": benchmark_passes,
|
|
238
|
+
"pass_rate": rate(benchmark_passes, len(benchmarks)),
|
|
239
|
+
"operations": sorted({str(event.get("operation")) for event in benchmarks}),
|
|
240
|
+
},
|
|
241
|
+
"approval": False,
|
|
242
|
+
}
|
|
243
|
+
if args.output:
|
|
244
|
+
write_json(output_path(args.output, "remediation-summary.json"), summary)
|
|
245
|
+
emit(summary, args.json)
|
|
246
|
+
return 0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
250
|
+
path = history_path(args.history)
|
|
251
|
+
events, errors = read_history(path)
|
|
252
|
+
result = {"status": "pass" if not errors else "fail", "history": str(path), "event_count": len(events), "errors": errors, "approval": False}
|
|
253
|
+
emit(result, args.json)
|
|
254
|
+
return 0 if not errors else 1
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def write_json(path: Path, value: dict[str, Any]) -> None:
|
|
258
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
temporary = path.with_name(f".{path.name}.tmp-{os.getpid()}")
|
|
260
|
+
temporary.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
261
|
+
os.replace(temporary, path)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def emit(value: dict[str, Any], as_json: bool) -> None:
|
|
265
|
+
if as_json:
|
|
266
|
+
print(json.dumps(value, indent=2, ensure_ascii=False))
|
|
267
|
+
else:
|
|
268
|
+
print(json.dumps(value, ensure_ascii=False))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def add_common(parser: argparse.ArgumentParser) -> None:
|
|
272
|
+
parser.add_argument("--history", help="Append-only JSONL history path")
|
|
273
|
+
parser.add_argument("--json", action="store_true")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
277
|
+
parser = argparse.ArgumentParser(description="MotionLoom remediation and benchmark history")
|
|
278
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
279
|
+
outcome = sub.add_parser("record-outcome", help="Record an explicitly user-confirmed remediation outcome")
|
|
280
|
+
add_common(outcome)
|
|
281
|
+
outcome.add_argument("--event-id", required=True)
|
|
282
|
+
outcome.add_argument("--issue-id", required=True)
|
|
283
|
+
outcome.add_argument("--issue-class")
|
|
284
|
+
outcome.add_argument("--summary", required=True)
|
|
285
|
+
outcome.add_argument("--root-cause")
|
|
286
|
+
outcome.add_argument("--resolution")
|
|
287
|
+
outcome.add_argument("--result", choices=["pass", "fail", "partial", "unknown"], required=True)
|
|
288
|
+
outcome.add_argument("--correction-count", type=int, default=0)
|
|
289
|
+
outcome.add_argument("--rerun-scope", action="append")
|
|
290
|
+
outcome.add_argument("--source-task-id")
|
|
291
|
+
outcome.add_argument("--evidence", action="append")
|
|
292
|
+
outcome.add_argument("--user-confirmed", action="store_true", required=True)
|
|
293
|
+
outcome.set_defaults(func=cmd_record_outcome)
|
|
294
|
+
benchmark = sub.add_parser("record-benchmark", help="Record a deterministic benchmark run")
|
|
295
|
+
add_common(benchmark)
|
|
296
|
+
benchmark.add_argument("--event-id", required=True)
|
|
297
|
+
benchmark.add_argument("--operation", required=True)
|
|
298
|
+
benchmark.add_argument("--task-id", required=True)
|
|
299
|
+
benchmark.add_argument("--scene", required=True)
|
|
300
|
+
benchmark.add_argument("--iterations", type=int, required=True)
|
|
301
|
+
benchmark.add_argument("--p95-ms", type=float, required=True)
|
|
302
|
+
benchmark.add_argument("--threshold-ms", type=float, required=True)
|
|
303
|
+
benchmark.add_argument("--status", choices=["pass", "fail"])
|
|
304
|
+
benchmark.add_argument("--evidence")
|
|
305
|
+
benchmark.set_defaults(func=cmd_record_benchmark)
|
|
306
|
+
summary = sub.add_parser("summary", help="Aggregate confirmed outcomes and benchmark history")
|
|
307
|
+
add_common(summary)
|
|
308
|
+
summary.add_argument("--output")
|
|
309
|
+
summary.set_defaults(func=cmd_summary)
|
|
310
|
+
validate = sub.add_parser("validate", help="Verify the append-only hash chain")
|
|
311
|
+
add_common(validate)
|
|
312
|
+
validate.set_defaults(func=cmd_validate)
|
|
313
|
+
return parser
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def main() -> int:
|
|
317
|
+
args = build_parser().parse_args()
|
|
318
|
+
try:
|
|
319
|
+
return int(args.func(args))
|
|
320
|
+
except (OSError, ValueError) as error:
|
|
321
|
+
print(f"MotionLoom remediation contract error: {error}", file=sys.stderr)
|
|
322
|
+
return 11
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
if __name__ == "__main__":
|
|
326
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MotionLoom render entrypoint.
|
|
4
|
+
|
|
5
|
+
Style contract: evidence-first, explicit runtime mode and no hidden approval
|
|
6
|
+
side effects. This is the cross-platform equivalent of render.sh and delegates
|
|
7
|
+
to the canonical Python snapshot renderer without requiring Bash.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SCENE_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> int:
|
|
23
|
+
parser = argparse.ArgumentParser(description="Render MotionLoom runtime snapshots")
|
|
24
|
+
parser.add_argument("scene")
|
|
25
|
+
parser.add_argument("--repo", default=None, help="Repository root; defaults to the script parent")
|
|
26
|
+
parser.add_argument("--progress", default="0,50,100")
|
|
27
|
+
parser.add_argument("--allow-placeholder", action="store_true")
|
|
28
|
+
args = parser.parse_args()
|
|
29
|
+
|
|
30
|
+
if not SCENE_RE.fullmatch(args.scene):
|
|
31
|
+
parser.error("scene id contains unsafe path characters")
|
|
32
|
+
|
|
33
|
+
repo = Path(args.repo).expanduser().resolve() if args.repo else Path(__file__).resolve().parents[1]
|
|
34
|
+
scene_dir = repo / "src" / "output" / args.scene
|
|
35
|
+
if not scene_dir.is_dir():
|
|
36
|
+
print(f"error: scene directory not found: {scene_dir}", file=sys.stderr)
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
progress = [int(value) for value in args.progress.split(",") if value.strip()]
|
|
41
|
+
except ValueError:
|
|
42
|
+
print("error: progress must be a comma-separated list of integers", file=sys.stderr)
|
|
43
|
+
return 1
|
|
44
|
+
|
|
45
|
+
if not progress or any(value < 0 or value > 100 for value in progress):
|
|
46
|
+
print("error: progress values must be between 0 and 100", file=sys.stderr)
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
# Import by path-independent package layout. The repository root is added
|
|
50
|
+
# only for this process; no cwd or shell-specific import assumptions.
|
|
51
|
+
sys.path.insert(0, str(repo))
|
|
52
|
+
from src.core.snapshot import render_snapshots # pylint: disable=import-outside-toplevel
|
|
53
|
+
|
|
54
|
+
result = render_snapshots(
|
|
55
|
+
args.scene,
|
|
56
|
+
scene_dir,
|
|
57
|
+
progress,
|
|
58
|
+
allow_placeholder=args.allow_placeholder or os.environ.get("ALLOW_PLACEHOLDER") == "1",
|
|
59
|
+
)
|
|
60
|
+
print(__import__("json").dumps(result, indent=2))
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
raise SystemExit(main())
|
package/scripts/report.py
CHANGED
|
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|
|
6
6
|
import argparse
|
|
7
7
|
import hashlib
|
|
8
8
|
import json
|
|
9
|
+
import shutil
|
|
9
10
|
import sys
|
|
10
11
|
from datetime import datetime, timezone
|
|
11
12
|
from pathlib import Path
|
|
@@ -49,6 +50,39 @@ def read_json(path: Path, default: dict | list | None = None):
|
|
|
49
50
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
50
51
|
|
|
51
52
|
|
|
53
|
+
def project_memory_path() -> Path:
|
|
54
|
+
return ROOT / ".motionloom" / "project-memory.json"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def memory_summary() -> dict | None:
|
|
58
|
+
path = project_memory_path()
|
|
59
|
+
if not path.is_file():
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
memory = read_json(path)
|
|
63
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
64
|
+
except (OSError, json.JSONDecodeError):
|
|
65
|
+
return {"path": ".motionloom/project-memory.json", "status": "invalid"}
|
|
66
|
+
freshness = memory.get("freshness") if isinstance(memory, dict) else {}
|
|
67
|
+
return {
|
|
68
|
+
"path": ".motionloom/project-memory.json",
|
|
69
|
+
"snapshot_path": "project-memory.json",
|
|
70
|
+
"memory_id": memory.get("memory_id"),
|
|
71
|
+
"status": freshness.get("status", "invalid"),
|
|
72
|
+
"sha256": digest,
|
|
73
|
+
"updated_at": memory.get("updated_at"),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def sync_memory_snapshot(task_dir: Path) -> dict | None:
|
|
78
|
+
source = project_memory_path()
|
|
79
|
+
if not source.is_file():
|
|
80
|
+
return None
|
|
81
|
+
destination = task_dir / "project-memory.json"
|
|
82
|
+
shutil.copy2(source, destination)
|
|
83
|
+
return memory_summary()
|
|
84
|
+
|
|
85
|
+
|
|
52
86
|
def has_symlink_component(path: Path) -> bool:
|
|
53
87
|
current = path
|
|
54
88
|
while True:
|
|
@@ -78,6 +112,7 @@ def candidate_is_current(candidate: dict) -> bool:
|
|
|
78
112
|
def init_task(args: argparse.Namespace) -> int:
|
|
79
113
|
task_dir = Path(args.output or ROOT / "artifacts" / args.task_id).resolve()
|
|
80
114
|
timestamp = now()
|
|
115
|
+
memory = memory_summary()
|
|
81
116
|
task = {
|
|
82
117
|
"schema_version": "1.0",
|
|
83
118
|
"task_id": args.task_id,
|
|
@@ -92,6 +127,8 @@ def init_task(args: argparse.Namespace) -> int:
|
|
|
92
127
|
"created_at": timestamp,
|
|
93
128
|
"updated_at": timestamp,
|
|
94
129
|
}
|
|
130
|
+
if memory:
|
|
131
|
+
task["project_memory"] = memory
|
|
95
132
|
report = {
|
|
96
133
|
"report_version": "1.0",
|
|
97
134
|
"task_id": args.task_id,
|
|
@@ -103,7 +140,7 @@ def init_task(args: argparse.Namespace) -> int:
|
|
|
103
140
|
"problems": [],
|
|
104
141
|
"structure_review": {"missing_files": [], "broken_references": [], "untracked_artifacts": []},
|
|
105
142
|
"browser_review": [],
|
|
106
|
-
"next_agent": [{"agent": args.agent, "action": "
|
|
143
|
+
"next_agent": [{"agent": args.agent, "action": "Recover Project Memory and run project analysis before generation.", "evidence_needed": ["project-memory.json", "project-context.json"]}],
|
|
107
144
|
"generated_at": timestamp,
|
|
108
145
|
}
|
|
109
146
|
handoff = {
|
|
@@ -114,7 +151,7 @@ def init_task(args: argparse.Namespace) -> int:
|
|
|
114
151
|
"state": "created",
|
|
115
152
|
"summary": "New animation task initialized.",
|
|
116
153
|
"next_actions": [{"action": "Analyze host project context", "skill": "motionloom", "evidence_needed": ["project-context.json"]}],
|
|
117
|
-
"required_artifacts": ["task.json", "execution-report.json", *EVIDENCE_ARTIFACTS],
|
|
154
|
+
"required_artifacts": ["task.json", "execution-report.json", *EVIDENCE_ARTIFACTS, *( ["project-memory.json"] if memory else [] )],
|
|
118
155
|
"blockers": [],
|
|
119
156
|
}
|
|
120
157
|
write_json(task_dir / "task.json", task)
|
|
@@ -122,6 +159,8 @@ def init_task(args: argparse.Namespace) -> int:
|
|
|
122
159
|
write_json(task_dir / "issue-register.json", {"version": "1.0", "task_id": args.task_id, "issues": []})
|
|
123
160
|
write_json(task_dir / "handoff.json", handoff)
|
|
124
161
|
write_json(task_dir / "artifact-manifest.json", {"manifest_version": "1.0", "task_id": args.task_id, "generated_at": timestamp, "artifacts": []})
|
|
162
|
+
if memory:
|
|
163
|
+
sync_memory_snapshot(task_dir)
|
|
125
164
|
(task_dir / "decision-log.jsonl").write_text("", encoding="utf-8")
|
|
126
165
|
print(json.dumps({"status": "created", "task_id": args.task_id, "task_dir": str(task_dir)}, ensure_ascii=False))
|
|
127
166
|
return 0
|
|
@@ -130,6 +169,7 @@ def init_task(args: argparse.Namespace) -> int:
|
|
|
130
169
|
def collect(args: argparse.Namespace) -> int:
|
|
131
170
|
task_dir = Path(args.task_dir).resolve()
|
|
132
171
|
task = read_json(task_dir / "task.json")
|
|
172
|
+
sync_memory_snapshot(task_dir)
|
|
133
173
|
excluded = {"artifact-manifest.json", "execution-report.json", "decision-log.jsonl"}
|
|
134
174
|
artifacts = []
|
|
135
175
|
for path in sorted(task_dir.rglob("*")):
|
|
@@ -146,6 +186,8 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
146
186
|
handoff = read_json(handoff_path)
|
|
147
187
|
required = set(handoff.get("required_artifacts", []))
|
|
148
188
|
required.update(name for name in EVIDENCE_ARTIFACTS if (task_dir / name).is_file())
|
|
189
|
+
if (task_dir / "project-memory.json").is_file():
|
|
190
|
+
required.add("project-memory.json")
|
|
149
191
|
handoff["required_artifacts"] = sorted(required)
|
|
150
192
|
write_json(handoff_path, handoff)
|
|
151
193
|
print(json.dumps({"status": "collected", "task_id": manifest["task_id"], "artifact_count": len(artifacts)}, ensure_ascii=False))
|
|
@@ -372,6 +414,8 @@ def check_report(args: argparse.Namespace) -> int:
|
|
|
372
414
|
task = read_json(task_dir / "task.json")
|
|
373
415
|
report = read_json(task_dir / "execution-report.json")
|
|
374
416
|
manifest = read_json(task_dir / "artifact-manifest.json", {"artifacts": []})
|
|
417
|
+
scene_manifest_path = ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json"
|
|
418
|
+
scene_manifest = read_json(scene_manifest_path, {})
|
|
375
419
|
state = task.get("state")
|
|
376
420
|
if not task.get("task_id") or not task.get("scene"):
|
|
377
421
|
errors.append("task.json requires task_id and scene")
|
|
@@ -384,6 +428,11 @@ def check_report(args: argparse.Namespace) -> int:
|
|
|
384
428
|
errors.append(f"artifact has invalid sha256: {artifact.get('path', '<unknown>')}")
|
|
385
429
|
if not (task_dir / artifact.get("path", "")).is_file():
|
|
386
430
|
errors.append(f"artifact path missing: {artifact.get('path', '<unknown>')}")
|
|
431
|
+
visual_truth_name = scene_manifest.get("visual_truth")
|
|
432
|
+
if visual_truth_name:
|
|
433
|
+
visual_truth_path = scene_manifest_path.parent / str(visual_truth_name)
|
|
434
|
+
if not visual_truth_path.is_file():
|
|
435
|
+
errors.append("scene manifest visual_truth points to a missing artifact")
|
|
387
436
|
if state in {"validated", "ready_for_pr", "confirmed"}:
|
|
388
437
|
quality = read_json(task_dir / "quality-report.json")
|
|
389
438
|
if quality.get("status") != "pass":
|
|
@@ -475,6 +524,11 @@ def render(args: argparse.Namespace) -> int:
|
|
|
475
524
|
lint = read_json(task_dir / "semantic-lint-report.json", {})
|
|
476
525
|
continuity = read_json(task_dir / "continuity-report.json", {})
|
|
477
526
|
fix_plan = read_json(task_dir / "fix-plan.json", {})
|
|
527
|
+
scene_manifest = read_json(ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json", {})
|
|
528
|
+
visual_truth = read_json(
|
|
529
|
+
ROOT / "src" / "output" / str(task.get("scene", "")) / str(scene_manifest.get("visual_truth", "")),
|
|
530
|
+
{},
|
|
531
|
+
) if scene_manifest.get("visual_truth") else {}
|
|
478
532
|
lines = [
|
|
479
533
|
f"# Animation Task Report — {task.get('task_id', task_dir.name)}",
|
|
480
534
|
"",
|
|
@@ -501,6 +555,10 @@ def render(args: argparse.Namespace) -> int:
|
|
|
501
555
|
"",
|
|
502
556
|
"## Browser review",
|
|
503
557
|
md_table(report.get("browser_review", []), [("Candidate", "candidate_id"), ("Decision", "decision"), ("Reviewer", "reviewer"), ("Evidence", "evidence")]),
|
|
558
|
+
"## Visual Truth",
|
|
559
|
+
f"- Status: **{visual_truth.get('status', 'not-run')}**; scene: `{visual_truth.get('scene', task.get('scene', ''))}`; approval: **{visual_truth.get('review_boundary', {}).get('approval', False)}**",
|
|
560
|
+
f"- Baseline: `{visual_truth.get('frames', {}).get('baseline', {}).get('path', '')}`; candidate: `{visual_truth.get('frames', {}).get('candidate', {}).get('path', '')}`",
|
|
561
|
+
f"- Changed pixels: **{visual_truth.get('comparison', {}).get('changed_pixels', 'not-run')}**; changed regions: **{len(visual_truth.get('comparison', {}).get('regions', []))}**",
|
|
504
562
|
"## Semantic motion lint",
|
|
505
563
|
f"- Status: **{lint.get('status', 'not-run')}**; errors: **{lint.get('summary', {}).get('errors', 0)}**; warnings: **{lint.get('summary', {}).get('warnings', 0)}**; blocking: **{lint.get('summary', {}).get('blocking', 0)}**",
|
|
506
564
|
md_table(lint.get("findings", []), [("Rule", "rule_id"), ("Severity", "severity"), ("Confidence", "confidence"), ("Message", "message"), ("Basis", "basis")]),
|
package/scripts/review-hook.py
CHANGED
|
@@ -7,6 +7,7 @@ import argparse
|
|
|
7
7
|
import hashlib
|
|
8
8
|
import json
|
|
9
9
|
import re
|
|
10
|
+
import shutil
|
|
10
11
|
import subprocess
|
|
11
12
|
import sys
|
|
12
13
|
from datetime import datetime, timedelta, timezone
|
|
@@ -68,6 +69,11 @@ def prepare(args: argparse.Namespace) -> int:
|
|
|
68
69
|
if not scene:
|
|
69
70
|
raise ValueError("task.json requires scene")
|
|
70
71
|
scene_dir, source_path, render_meta, context_path = paths(task_dir, task)
|
|
72
|
+
memory_path = ROOT / ".motionloom" / "project-memory.json"
|
|
73
|
+
memory = read_json(memory_path, {}) if memory_path.is_file() else {}
|
|
74
|
+
memory_status = (memory.get("freshness") or {}).get("status")
|
|
75
|
+
if memory_path.is_file() and memory_status not in {"fresh", None}:
|
|
76
|
+
raise ValueError(f"project memory is {memory_status}; run motionloom memory refresh/analyze before browser review")
|
|
71
77
|
spec = read_json(scene_dir / "motion-spec.json")
|
|
72
78
|
context_hash = (spec.get("context_binding") or {}).get("context_sha256") or sha256(context_path)
|
|
73
79
|
source_hash = sha256(source_path)
|
|
@@ -92,6 +98,9 @@ def prepare(args: argparse.Namespace) -> int:
|
|
|
92
98
|
"prepared_at": now(),
|
|
93
99
|
"expires_at": (datetime.now(timezone.utc) + CANDIDATE_TTL).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
94
100
|
}
|
|
101
|
+
if memory_path.is_file():
|
|
102
|
+
candidate["project_memory_sha256"] = sha256(memory_path)
|
|
103
|
+
candidate["project_memory_id"] = memory.get("memory_id")
|
|
95
104
|
(scene_dir / "browser-review.json").write_text(json.dumps(candidate, indent=2) + "\n", encoding="utf-8")
|
|
96
105
|
task["state"] = "review_required"
|
|
97
106
|
task["updated_at"] = now()
|
|
@@ -109,9 +118,11 @@ def prepare(args: argparse.Namespace) -> int:
|
|
|
109
118
|
report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
110
119
|
handoff_path = task_dir / "handoff.json"
|
|
111
120
|
handoff = read_json(handoff_path)
|
|
112
|
-
|
|
121
|
+
if memory_path.is_file():
|
|
122
|
+
shutil.copy2(memory_path, task_dir / "project-memory.json")
|
|
123
|
+
handoff.update({"state": "review_required", "to_agent": "browser-review-agent", "summary": "Open the exact rendered candidate in the internal Dev Lab and obtain user approval before PR.", "next_actions": [{"action": "Open internal Dev Lab candidate", "kind": "browser_review", "agent": "browser-review-agent", "skill": "browser-review", "url": url, "candidate_id": cid, "requires_user_approval": True, "evidence_needed": ["review.json"], "output_artifacts": ["review.json"]}], "required_artifacts": sorted(set(handoff.get("required_artifacts", []) + ["browser-review.json", "review.json", "semantic-lint-benchmark.json", "evidence-verifier-report.json", "runtime-adapters/runtime-evidence.json"] + (["project-memory.json"] if memory_path.is_file() else [])))})
|
|
113
124
|
handoff_path.write_text(json.dumps(handoff, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
114
|
-
subprocess.run([
|
|
125
|
+
subprocess.run([sys.executable, str(ROOT / "scripts/devlab.py"), scene, "--prepare-only", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
|
|
115
126
|
subprocess.run([sys.executable, str(ROOT / "scripts/report.py"), "collect", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
|
|
116
127
|
print(json.dumps({"status": "review_required", "task_id": task["task_id"], "candidate_id": cid, "url": url, "agent": "browser-review-agent", "action": "Open this exact URL in the internal browser; ask the user to inspect frames 0/50/100 and approve or request changes.", "requires_user_approval": True, "output_artifacts": ["review.json"]}, ensure_ascii=False))
|
|
117
128
|
return 0
|
package/scripts/skill-doctor.py
CHANGED
|
@@ -13,7 +13,16 @@ from pathlib import Path
|
|
|
13
13
|
ROOT = Path(__file__).resolve().parents[1]
|
|
14
14
|
REQUIRED_FILES = ["SKILL.md", "agent-card.json", "package.json"]
|
|
15
15
|
REQUIRED_DIRS = ["scripts", "templates", "references", "schemas"]
|
|
16
|
-
REQUIRED_SCRIPT_FILES = [
|
|
16
|
+
REQUIRED_SCRIPT_FILES = [
|
|
17
|
+
"scripts/report-contract.py",
|
|
18
|
+
"scripts/review-hook.py",
|
|
19
|
+
"scripts/quality-gate.py",
|
|
20
|
+
"scripts/runtime-adapters.mjs",
|
|
21
|
+
"scripts/project-memory.py",
|
|
22
|
+
"scripts/project_memory_loader.py",
|
|
23
|
+
"scripts/analyze.py",
|
|
24
|
+
"scripts/devlab.py",
|
|
25
|
+
]
|
|
17
26
|
REQUIRED_SCHEMAS = [
|
|
18
27
|
"task.schema.json",
|
|
19
28
|
"execution-report.schema.json",
|
|
@@ -26,6 +35,7 @@ REQUIRED_SCHEMAS = [
|
|
|
26
35
|
"provenance.schema.json",
|
|
27
36
|
"capability-registry.schema.json",
|
|
28
37
|
"motion-ir.schema.json",
|
|
38
|
+
"project-memory.schema.json",
|
|
29
39
|
]
|
|
30
40
|
|
|
31
41
|
|
|
@@ -121,7 +131,7 @@ def run() -> int:
|
|
|
121
131
|
package_path = ROOT / "package.json"
|
|
122
132
|
try:
|
|
123
133
|
package = json.loads(package_path.read_text(encoding="utf-8"))
|
|
124
|
-
for script in ("test", "validate", "doctor", "report", "report:check", "review"):
|
|
134
|
+
for script in ("test", "validate", "doctor", "report", "report:check", "review", "memory:bootstrap", "memory:recover", "memory:validate", "devlab", "pack:dotlottie"):
|
|
125
135
|
if script not in package.get("scripts", {}):
|
|
126
136
|
warnings.append({"code": "missing_package_script", "message": f"package.json has no {script} script."})
|
|
127
137
|
except (FileNotFoundError, json.JSONDecodeError) as exc:
|