motionloom 2.1.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 +27 -0
- package/README.md +6 -0
- package/ROADMAP.md +9 -5
- package/SECURITY.md +3 -2
- package/SKILL.md +27 -4
- package/agent-card.json +23 -2
- package/agent-surfaces.json +79 -0
- package/bin/motionloom.mjs +11 -1
- package/docs/AGENT-INTEGRATION.md +47 -0
- package/docs/CHECKLIST.md +2 -1
- package/docs/STATUS.md +1 -1
- package/docs/audits/ci-replay-remediation-2026-08-13.md +33 -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 +22 -7
- package/references/agent-interoperability.md +29 -0
- package/references/intelligence-core.md +4 -0
- package/schemas/agent-surfaces.schema.json +78 -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/devlab.py +1 -1
- package/scripts/discovery.py +257 -0
- package/scripts/docs-audit.py +17 -1
- package/scripts/pr.py +1 -0
- package/scripts/quality-gate.py +43 -3
- package/scripts/remediation-learning.py +326 -0
- package/scripts/report.py +16 -0
- package/scripts/visual-truth.py +310 -0
- package/src/output/browser-review-smoke/manifest.json +1 -0
- package/src/output/browser-review-smoke/visual-truth.json +68 -0
- package/tests/scripts/run_tests.py +59 -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())
|
package/scripts/report.py
CHANGED
|
@@ -414,6 +414,8 @@ def check_report(args: argparse.Namespace) -> int:
|
|
|
414
414
|
task = read_json(task_dir / "task.json")
|
|
415
415
|
report = read_json(task_dir / "execution-report.json")
|
|
416
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, {})
|
|
417
419
|
state = task.get("state")
|
|
418
420
|
if not task.get("task_id") or not task.get("scene"):
|
|
419
421
|
errors.append("task.json requires task_id and scene")
|
|
@@ -426,6 +428,11 @@ def check_report(args: argparse.Namespace) -> int:
|
|
|
426
428
|
errors.append(f"artifact has invalid sha256: {artifact.get('path', '<unknown>')}")
|
|
427
429
|
if not (task_dir / artifact.get("path", "")).is_file():
|
|
428
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")
|
|
429
436
|
if state in {"validated", "ready_for_pr", "confirmed"}:
|
|
430
437
|
quality = read_json(task_dir / "quality-report.json")
|
|
431
438
|
if quality.get("status") != "pass":
|
|
@@ -517,6 +524,11 @@ def render(args: argparse.Namespace) -> int:
|
|
|
517
524
|
lint = read_json(task_dir / "semantic-lint-report.json", {})
|
|
518
525
|
continuity = read_json(task_dir / "continuity-report.json", {})
|
|
519
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 {}
|
|
520
532
|
lines = [
|
|
521
533
|
f"# Animation Task Report — {task.get('task_id', task_dir.name)}",
|
|
522
534
|
"",
|
|
@@ -543,6 +555,10 @@ def render(args: argparse.Namespace) -> int:
|
|
|
543
555
|
"",
|
|
544
556
|
"## Browser review",
|
|
545
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', []))}**",
|
|
546
562
|
"## Semantic motion lint",
|
|
547
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)}**",
|
|
548
564
|
md_table(lint.get("findings", []), [("Rule", "rule_id"), ("Severity", "severity"), ("Confidence", "confidence"), ("Message", "message"), ("Basis", "basis")]),
|