motionloom 2.1.0 → 2.3.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 +51 -0
- package/README.md +60 -15
- package/ROADMAP.md +11 -5
- package/SECURITY.md +3 -2
- package/SKILL.md +40 -5
- package/agent-card.json +42 -4
- package/agent-surfaces.json +86 -0
- package/bin/motionloom.mjs +26 -3
- package/docs/AGENT-INTEGRATION.md +60 -0
- package/docs/CHECKLIST.md +7 -1
- package/docs/STATUS.md +2 -2
- package/docs/audits/ci-replay-remediation-2026-08-13.md +33 -0
- package/docs/releases/2.2.0.md +35 -0
- package/docs/releases/2.3.0.md +33 -0
- package/docs/releases/npm-publish-from-workstation.md +6 -6
- package/examples/agent-consumer/README.md +18 -0
- package/examples/agent-consumer/ai-generated-pilot/hero-male.json +10 -0
- package/examples/agent-consumer/ai-generated-pilot-provenance.json +55 -0
- package/examples/agent-consumer/fixture-manifest.json +82 -0
- package/package.json +31 -7
- package/references/agent-interoperability.md +40 -0
- package/references/intelligence-core.md +12 -2
- package/schemas/agent-surfaces.schema.json +78 -0
- package/schemas/asset-provenance.schema.json +183 -0
- package/schemas/remediation-history.schema.json +23 -0
- package/schemas/scene-manifest.schema.json +2 -0
- package/schemas/visual-truth.schema.json +80 -0
- package/scripts/asset-provenance.py +390 -0
- package/scripts/devlab.py +1 -1
- package/scripts/discovery.py +257 -0
- package/scripts/docs-audit.py +30 -2
- package/scripts/pr.py +3 -0
- package/scripts/quality-gate.py +81 -3
- package/scripts/remediation-learning.py +326 -0
- package/scripts/report.py +66 -0
- package/scripts/setup.mjs +472 -0
- package/scripts/skill-doctor.py +2 -1
- package/scripts/visual-truth.py +310 -0
- package/src/output/browser-review-smoke/asset-provenance.json +77 -0
- package/src/output/browser-review-smoke/manifest.json +2 -0
- package/src/output/browser-review-smoke/visual-truth.json +68 -0
- package/tests/scripts/run_tests.py +83 -0
package/scripts/docs-audit.py
CHANGED
|
@@ -23,7 +23,7 @@ for markdown in sorted(ROOT.rglob("*.md")):
|
|
|
23
23
|
if not (markdown.parent / target).resolve().exists():
|
|
24
24
|
errors.append(f"{markdown.relative_to(ROOT)} -> missing {target}")
|
|
25
25
|
|
|
26
|
-
for relative in ["package.json", "agent-card.json", "project-context.example.json", "tests/evals/project-corpus.json"]:
|
|
26
|
+
for relative in ["package.json", "agent-card.json", "agent-surfaces.json", "schemas/agent-surfaces.schema.json", "schemas/provenance.schema.json", "schemas/asset-provenance.schema.json", "schemas/scene-manifest.schema.json", "schemas/visual-truth.schema.json", "schemas/remediation-history.schema.json", "project-context.example.json", "examples/agent-consumer/ai-generated-pilot-provenance.json", "tests/evals/project-corpus.json"]:
|
|
27
27
|
path = ROOT / relative
|
|
28
28
|
try:
|
|
29
29
|
json.loads(path.read_text(encoding="utf-8"))
|
|
@@ -36,11 +36,37 @@ for required in ["author", "repository", "homepage", "bugs", "license", "engines
|
|
|
36
36
|
errors.append(f"package.json: missing public metadata {required}")
|
|
37
37
|
if package.get("packageManager") != "pnpm@11.20.0":
|
|
38
38
|
errors.append("package.json: packageManager must pin pnpm@11.20.0")
|
|
39
|
+
for required_surface in [".agents", ".claude", ".codex", "AGENTS.md", "agent-surfaces.json"]:
|
|
40
|
+
if required_surface not in package.get("files", []):
|
|
41
|
+
errors.append(f"package.json: files must include Agent surface {required_surface}")
|
|
42
|
+
for required_path in ["scripts/asset-provenance.py", "schemas/asset-provenance.schema.json", "examples/agent-consumer/ai-generated-pilot-provenance.json"]:
|
|
43
|
+
if required_path not in package.get("files", []):
|
|
44
|
+
errors.append(f"package.json: files must include asset provenance contract {required_path}")
|
|
45
|
+
for onboarding_script in ["setup", "setup:dry", "status", "repair"]:
|
|
46
|
+
if onboarding_script not in package.get("scripts", {}):
|
|
47
|
+
errors.append(f"package.json: missing onboarding script {onboarding_script}")
|
|
48
|
+
|
|
49
|
+
sys.path.insert(0, str(ROOT))
|
|
50
|
+
try:
|
|
51
|
+
from scripts.discovery import validate as validate_discovery
|
|
52
|
+
discovery = validate_discovery(ROOT)
|
|
53
|
+
for discovery_error in discovery.get("errors", []):
|
|
54
|
+
errors.append(f"agent discovery: {discovery_error}")
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
errors.append(f"agent discovery: validator could not load: {exc}")
|
|
57
|
+
|
|
58
|
+
for required_doc in ["docs/AGENT-INTEGRATION.md", "references/agent-interoperability.md"]:
|
|
59
|
+
if not (ROOT / required_doc).is_file():
|
|
60
|
+
errors.append(f"missing Agent interoperability document: {required_doc}")
|
|
39
61
|
|
|
40
62
|
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
41
|
-
for heading in ["Why MotionLoom", "Quick start", "Durable Project Memory", "Evidence, trust and review", "Documentation map"]:
|
|
63
|
+
for heading in ["Why MotionLoom", "Quick start", "Durable Project Memory", "Evidence, trust and review", "Asset provenance tiers", "Documentation map"]:
|
|
42
64
|
if f"## {heading}" not in readme:
|
|
43
65
|
errors.append(f"README.md: missing heading {heading}")
|
|
66
|
+
if "npx --yes motionloom setup" not in readme:
|
|
67
|
+
errors.append("README.md: missing one-command onboarding recipe")
|
|
68
|
+
if "npx --yes motionloom setup" not in (ROOT / "docs/AGENT-INTEGRATION.md").read_text(encoding="utf-8"):
|
|
69
|
+
errors.append("docs/AGENT-INTEGRATION.md: missing one-command onboarding recipe")
|
|
44
70
|
|
|
45
71
|
workflow_dir = ROOT / ".github" / "workflows"
|
|
46
72
|
for workflow in sorted(workflow_dir.glob("*.yml")):
|
|
@@ -61,6 +87,8 @@ for workflow in sorted(workflow_dir.glob("*.yml")):
|
|
|
61
87
|
for required in ["name:", "on:", "jobs:", "permissions:"]:
|
|
62
88
|
if required not in text:
|
|
63
89
|
errors.append(f"{workflow.relative_to(ROOT)}: missing {required}")
|
|
90
|
+
if "--require-asset-provenance" not in (workflow_dir / "quality.yml").read_text(encoding="utf-8"):
|
|
91
|
+
errors.append("quality.yml: missing fail-closed asset provenance production gate")
|
|
64
92
|
if "pull_request:" in text and "secrets." in text:
|
|
65
93
|
errors.append(f"{workflow.relative_to(ROOT)}: secrets referenced in pull_request workflow")
|
|
66
94
|
|
package/scripts/pr.py
CHANGED
|
@@ -94,6 +94,8 @@ def main() -> int:
|
|
|
94
94
|
"--context", args.context or str(repo / "project-context.json"),
|
|
95
95
|
"--task-dir", str(task_dir),
|
|
96
96
|
"--require-browser-review",
|
|
97
|
+
"--require-visual-truth",
|
|
98
|
+
"--require-asset-provenance",
|
|
97
99
|
]
|
|
98
100
|
print("== running context-bound quality gate ==")
|
|
99
101
|
run(repo, [python, *quality_args])
|
|
@@ -120,6 +122,7 @@ def main() -> int:
|
|
|
120
122
|
f"- snapshot frames: 0/50/100% in src/output/{args.scene}/snapshot/\n"
|
|
121
123
|
"- context-bound quality gate: passed\n"
|
|
122
124
|
f"- brand tokens bound from {args.context or 'project-context.json'}"
|
|
125
|
+
"\n- asset provenance: production_eligible (human approval remains separate)"
|
|
123
126
|
)
|
|
124
127
|
run(repo, ["git", "commit", "-m", commit_message])
|
|
125
128
|
|
package/scripts/quality-gate.py
CHANGED
|
@@ -47,6 +47,22 @@ 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
|
+
|
|
58
|
+
def _load_asset_provenance():
|
|
59
|
+
path = ROOT / "scripts" / "asset-provenance.py"
|
|
60
|
+
loader = importlib.util.spec_from_file_location("asset_provenance", path)
|
|
61
|
+
module = importlib.util.module_from_spec(loader)
|
|
62
|
+
loader.loader.exec_module(module)
|
|
63
|
+
return module
|
|
64
|
+
|
|
65
|
+
|
|
50
66
|
def _json(path: Path):
|
|
51
67
|
try:
|
|
52
68
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
@@ -66,7 +82,7 @@ def _telemetry_bundle_sha256(task_dir: Path) -> str:
|
|
|
66
82
|
return hashlib.sha256(json.dumps(entries, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
|
67
83
|
|
|
68
84
|
|
|
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]:
|
|
85
|
+
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, require_asset_provenance: bool = False, asset_provenance_path: Path | None = None) -> list[str]:
|
|
70
86
|
issues = []
|
|
71
87
|
manifest_path = scene_dir / "manifest.json"
|
|
72
88
|
spec_path = scene_dir / "motion-spec.json"
|
|
@@ -100,6 +116,28 @@ def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = F
|
|
|
100
116
|
if not (spec.get("accessibility") or {}).get("reduced_motion"):
|
|
101
117
|
issues.append("motion spec has no reduced-motion policy")
|
|
102
118
|
|
|
119
|
+
asset_provenance_name = manifest.get("asset_provenance")
|
|
120
|
+
if require_asset_provenance and not isinstance(asset_provenance_name, str):
|
|
121
|
+
issues.append("asset provenance gate requires manifest.asset_provenance")
|
|
122
|
+
if asset_provenance_name or asset_provenance_path:
|
|
123
|
+
resolved_asset_provenance = asset_provenance_path or (scene_dir / str(asset_provenance_name)).resolve()
|
|
124
|
+
if scene_dir.resolve() not in resolved_asset_provenance.parents or not resolved_asset_provenance.is_file():
|
|
125
|
+
issues.append("manifest.asset_provenance must point to an existing file inside the scene directory")
|
|
126
|
+
else:
|
|
127
|
+
try:
|
|
128
|
+
asset_module = _load_asset_provenance()
|
|
129
|
+
asset_result = asset_module.evaluate(
|
|
130
|
+
resolved_asset_provenance,
|
|
131
|
+
base=scene_dir,
|
|
132
|
+
mode="production" if require_asset_provenance else "runtime",
|
|
133
|
+
manifest=manifest,
|
|
134
|
+
)
|
|
135
|
+
issues.extend(f"asset provenance: {issue}" for issue in asset_result.get("errors", []))
|
|
136
|
+
if require_asset_provenance and not asset_result.get("summary", {}).get("production_eligible"):
|
|
137
|
+
issues.append("asset provenance is not production_eligible; human approval remains separate")
|
|
138
|
+
except (OSError, ValueError, AttributeError) as exc:
|
|
139
|
+
issues.append(f"asset provenance contract: {exc}")
|
|
140
|
+
|
|
103
141
|
checks = manifest.get("checks")
|
|
104
142
|
if not isinstance(checks, list) or not checks:
|
|
105
143
|
issues.append("manifest.checks must contain the Dev Lab quality checklist")
|
|
@@ -326,6 +364,36 @@ def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = F
|
|
|
326
364
|
issues.append(f"signed attestation required binding source is missing: {field}")
|
|
327
365
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
328
366
|
issues.append(f"signed attestation contract: {exc}")
|
|
367
|
+
if require_visual_truth:
|
|
368
|
+
visual_name = manifest.get("visual_truth")
|
|
369
|
+
if not isinstance(visual_name, str) or not visual_name.strip():
|
|
370
|
+
issues.append("visual truth gate requires manifest.visual_truth")
|
|
371
|
+
else:
|
|
372
|
+
visual_path = (scene_dir / visual_name).resolve()
|
|
373
|
+
if not visual_path.is_file() or scene_dir.resolve() not in visual_path.parents:
|
|
374
|
+
issues.append("manifest.visual_truth must point to an existing file inside the scene directory")
|
|
375
|
+
else:
|
|
376
|
+
try:
|
|
377
|
+
task_id = None
|
|
378
|
+
motion_ir_hash = None
|
|
379
|
+
if task_dir:
|
|
380
|
+
task = _json(task_dir / "task.json")
|
|
381
|
+
task_id = task.get("task_id")
|
|
382
|
+
motion_ir = task_dir / "motion-ir.json"
|
|
383
|
+
motion_ir_hash = _sha256_file(motion_ir) if motion_ir.is_file() else None
|
|
384
|
+
visual_truth = _load_visual_truth()
|
|
385
|
+
visual_issues = visual_truth.validate_report(
|
|
386
|
+
visual_path,
|
|
387
|
+
ROOT,
|
|
388
|
+
scene_dir.name,
|
|
389
|
+
task_id,
|
|
390
|
+
source_sha_for_evidence,
|
|
391
|
+
manifest_sha_for_evidence,
|
|
392
|
+
motion_ir_hash,
|
|
393
|
+
)
|
|
394
|
+
issues.extend(f"visual truth: {issue}" for issue in visual_issues)
|
|
395
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
396
|
+
issues.append(f"visual truth contract: {exc}")
|
|
329
397
|
return issues
|
|
330
398
|
|
|
331
399
|
|
|
@@ -343,6 +411,9 @@ def main() -> int:
|
|
|
343
411
|
parser.add_argument("--require-benchmark", action="store_true")
|
|
344
412
|
parser.add_argument("--require-telemetry", action="store_true")
|
|
345
413
|
parser.add_argument("--require-attestation", action="store_true")
|
|
414
|
+
parser.add_argument("--require-visual-truth", action="store_true")
|
|
415
|
+
parser.add_argument("--require-asset-provenance", action="store_true")
|
|
416
|
+
parser.add_argument("--asset-provenance")
|
|
346
417
|
parser.add_argument("--attestation")
|
|
347
418
|
parser.add_argument("--trust-policy")
|
|
348
419
|
args = parser.parse_args()
|
|
@@ -357,20 +428,27 @@ def main() -> int:
|
|
|
357
428
|
task_dir = Path(args.task_dir).resolve() if args.task_dir else None
|
|
358
429
|
attestation_path = Path(args.attestation).resolve() if args.attestation else None
|
|
359
430
|
trust_policy_path = Path(args.trust_policy).resolve() if args.trust_policy else None
|
|
431
|
+
asset_provenance_path = Path(args.asset_provenance).resolve() if args.asset_provenance else None
|
|
360
432
|
scenes = [root / "src" / "output" / args.scene] if args.scene else sorted(p for p in output_root.iterdir() if p.is_dir()) if output_root.exists() else []
|
|
361
433
|
if not scenes:
|
|
362
434
|
print("QUALITY GATE: no scene outputs found")
|
|
363
435
|
return 0
|
|
364
436
|
failed = False
|
|
365
437
|
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)
|
|
438
|
+
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, args.require_asset_provenance, asset_provenance_path)
|
|
367
439
|
if issues:
|
|
368
440
|
failed = True
|
|
369
441
|
print(f"REJECTED {scene_dir.name}:")
|
|
370
442
|
for issue in issues:
|
|
371
443
|
print(f" - {issue}")
|
|
372
444
|
else:
|
|
373
|
-
|
|
445
|
+
suffixes = []
|
|
446
|
+
if args.require_visual_truth:
|
|
447
|
+
suffixes.append("visual-truth contract")
|
|
448
|
+
if args.require_asset_provenance:
|
|
449
|
+
suffixes.append("asset provenance production eligibility")
|
|
450
|
+
suffix = f" + {' + '.join(suffixes)}" if suffixes else ""
|
|
451
|
+
print(f"ACCEPTED {scene_dir.name}: context + spec + runtime snapshots + browser-review candidate + checklist{suffix}")
|
|
374
452
|
return 1 if failed else 0
|
|
375
453
|
|
|
376
454
|
|
|
@@ -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
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
7
|
import hashlib
|
|
8
|
+
import importlib.util
|
|
8
9
|
import json
|
|
9
10
|
import shutil
|
|
10
11
|
import sys
|
|
@@ -54,6 +55,32 @@ def project_memory_path() -> Path:
|
|
|
54
55
|
return ROOT / ".motionloom" / "project-memory.json"
|
|
55
56
|
|
|
56
57
|
|
|
58
|
+
def asset_provenance_module():
|
|
59
|
+
path = ROOT / "scripts" / "asset-provenance.py"
|
|
60
|
+
loader = importlib.util.spec_from_file_location("motionloom_asset_provenance", path)
|
|
61
|
+
module = importlib.util.module_from_spec(loader)
|
|
62
|
+
loader.loader.exec_module(module)
|
|
63
|
+
return module
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def asset_provenance_result(scene_manifest_path: Path, scene_manifest: dict, mode: str = "runtime") -> dict:
|
|
67
|
+
name = scene_manifest.get("asset_provenance")
|
|
68
|
+
if not name:
|
|
69
|
+
return {"status": "not-run", "errors": ["scene manifest has no asset_provenance"]}
|
|
70
|
+
provenance_path = (scene_manifest_path.parent / str(name)).resolve()
|
|
71
|
+
if scene_manifest_path.parent.resolve() not in provenance_path.parents or not provenance_path.is_file():
|
|
72
|
+
return {"status": "fail", "errors": ["scene manifest asset_provenance points to a missing or unsafe artifact"]}
|
|
73
|
+
try:
|
|
74
|
+
return asset_provenance_module().evaluate(
|
|
75
|
+
provenance_path,
|
|
76
|
+
base=scene_manifest_path.parent,
|
|
77
|
+
mode=mode,
|
|
78
|
+
manifest=scene_manifest,
|
|
79
|
+
)
|
|
80
|
+
except (OSError, ValueError, AttributeError) as exc:
|
|
81
|
+
return {"status": "fail", "errors": [f"asset provenance contract: {exc}"]}
|
|
82
|
+
|
|
83
|
+
|
|
57
84
|
def memory_summary() -> dict | None:
|
|
58
85
|
path = project_memory_path()
|
|
59
86
|
if not path.is_file():
|
|
@@ -414,6 +441,8 @@ def check_report(args: argparse.Namespace) -> int:
|
|
|
414
441
|
task = read_json(task_dir / "task.json")
|
|
415
442
|
report = read_json(task_dir / "execution-report.json")
|
|
416
443
|
manifest = read_json(task_dir / "artifact-manifest.json", {"artifacts": []})
|
|
444
|
+
scene_manifest_path = ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json"
|
|
445
|
+
scene_manifest = read_json(scene_manifest_path, {})
|
|
417
446
|
state = task.get("state")
|
|
418
447
|
if not task.get("task_id") or not task.get("scene"):
|
|
419
448
|
errors.append("task.json requires task_id and scene")
|
|
@@ -426,6 +455,26 @@ def check_report(args: argparse.Namespace) -> int:
|
|
|
426
455
|
errors.append(f"artifact has invalid sha256: {artifact.get('path', '<unknown>')}")
|
|
427
456
|
if not (task_dir / artifact.get("path", "")).is_file():
|
|
428
457
|
errors.append(f"artifact path missing: {artifact.get('path', '<unknown>')}")
|
|
458
|
+
visual_truth_name = scene_manifest.get("visual_truth")
|
|
459
|
+
if visual_truth_name:
|
|
460
|
+
visual_truth_path = scene_manifest_path.parent / str(visual_truth_name)
|
|
461
|
+
if not visual_truth_path.is_file():
|
|
462
|
+
errors.append("scene manifest visual_truth points to a missing artifact")
|
|
463
|
+
provenance_mode = "production" if state in {"ready_for_pr", "confirmed"} else "runtime"
|
|
464
|
+
provenance = asset_provenance_result(scene_manifest_path, scene_manifest, provenance_mode)
|
|
465
|
+
if provenance.get("status") == "fail":
|
|
466
|
+
errors.extend(f"asset provenance: {error}" for error in provenance.get("errors", []))
|
|
467
|
+
# Legacy report-contract fixtures may exercise lifecycle/report behavior
|
|
468
|
+
# without materializing a scene manifest. Do not invent provenance for
|
|
469
|
+
# those synthetic tasks. Once a real scene manifest exists, readiness is
|
|
470
|
+
# fail-closed and its asset_provenance reference is mandatory for PR
|
|
471
|
+
# states; the production quality gate remains independently strict when
|
|
472
|
+
# --require-asset-provenance is supplied.
|
|
473
|
+
if state in {"ready_for_pr", "confirmed"} and scene_manifest_path.is_file():
|
|
474
|
+
if provenance.get("status") != "pass":
|
|
475
|
+
errors.append("ready-for-PR or confirmed task requires a passing asset provenance production check")
|
|
476
|
+
elif not provenance.get("summary", {}).get("production_eligible"):
|
|
477
|
+
errors.append("ready-for-PR or confirmed task requires asset provenance production_eligible")
|
|
429
478
|
if state in {"validated", "ready_for_pr", "confirmed"}:
|
|
430
479
|
quality = read_json(task_dir / "quality-report.json")
|
|
431
480
|
if quality.get("status") != "pass":
|
|
@@ -517,6 +566,16 @@ def render(args: argparse.Namespace) -> int:
|
|
|
517
566
|
lint = read_json(task_dir / "semantic-lint-report.json", {})
|
|
518
567
|
continuity = read_json(task_dir / "continuity-report.json", {})
|
|
519
568
|
fix_plan = read_json(task_dir / "fix-plan.json", {})
|
|
569
|
+
scene_manifest = read_json(ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json", {})
|
|
570
|
+
visual_truth = read_json(
|
|
571
|
+
ROOT / "src" / "output" / str(task.get("scene", "")) / str(scene_manifest.get("visual_truth", "")),
|
|
572
|
+
{},
|
|
573
|
+
) if scene_manifest.get("visual_truth") else {}
|
|
574
|
+
provenance = asset_provenance_result(
|
|
575
|
+
ROOT / "src" / "output" / str(task.get("scene", "")) / "manifest.json",
|
|
576
|
+
scene_manifest,
|
|
577
|
+
"production" if task.get("state") in {"ready_for_pr", "confirmed"} else "runtime",
|
|
578
|
+
)
|
|
520
579
|
lines = [
|
|
521
580
|
f"# Animation Task Report — {task.get('task_id', task_dir.name)}",
|
|
522
581
|
"",
|
|
@@ -543,6 +602,13 @@ def render(args: argparse.Namespace) -> int:
|
|
|
543
602
|
"",
|
|
544
603
|
"## Browser review",
|
|
545
604
|
md_table(report.get("browser_review", []), [("Candidate", "candidate_id"), ("Decision", "decision"), ("Reviewer", "reviewer"), ("Evidence", "evidence")]),
|
|
605
|
+
"## Visual Truth",
|
|
606
|
+
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)}**",
|
|
607
|
+
f"- Baseline: `{visual_truth.get('frames', {}).get('baseline', {}).get('path', '')}`; candidate: `{visual_truth.get('frames', {}).get('candidate', {}).get('path', '')}`",
|
|
608
|
+
f"- Changed pixels: **{visual_truth.get('comparison', {}).get('changed_pixels', 'not-run')}**; changed regions: **{len(visual_truth.get('comparison', {}).get('regions', []))}**",
|
|
609
|
+
"## Asset provenance",
|
|
610
|
+
f"- Status: **{provenance.get('status', 'not-run')}**; authority: **{provenance.get('summary', {}).get('authority', 'unknown')}**; declared readiness: **{provenance.get('summary', {}).get('declared_readiness', 'blocked')}**; effective readiness: **{provenance.get('summary', {}).get('effective_readiness', 'blocked')}**",
|
|
611
|
+
f"- Production eligible: **{provenance.get('summary', {}).get('production_eligible', False)}**; production approved: **{provenance.get('summary', {}).get('production_approved', False)}**; errors: **{len(provenance.get('errors', []))}**",
|
|
546
612
|
"## Semantic motion lint",
|
|
547
613
|
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
614
|
md_table(lint.get("findings", []), [("Rule", "rule_id"), ("Severity", "severity"), ("Confidence", "confidence"), ("Message", "message"), ("Basis", "basis")]),
|