motionloom 2.6.0 → 2.7.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.md +3 -1
- package/CHANGELOG.md +68 -0
- package/CONTRIBUTING.md +3 -1
- package/README.md +20 -4
- package/SECURITY.md +5 -5
- package/SKILL.md +15 -5
- package/agent-card.json +77 -14
- package/agent-surfaces.json +30 -7
- package/artifact-adapter-registry.json +568 -20
- package/bin/motionloom.mjs +25 -2
- package/capability-registry.json +58 -234
- package/dev-lab/public/devlab.js +36 -3
- package/dev-lab/public/index.html +6 -2
- package/docs/ACTION-SEPARATION.md +104 -0
- package/docs/ASSET-GENERATION-PLANNER.md +101 -0
- package/docs/BRANCH-PROTECTION.md +44 -0
- package/docs/EXTERNAL-CORPUS.md +26 -0
- package/docs/STATUS.md +3 -2
- package/docs/audits/field-test-after-hardening-2026-08-21.md +25 -0
- package/docs/releases/2.6.1.md +57 -0
- package/docs/releases/2.7.0.md +98 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.00.json +32 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.01.json +32 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.02.json +32 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/envelopes/walk.03.json +32 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/hero-walk-action-manifest.json +81 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.00.json +26 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.01.json +26 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.02.json +26 -0
- package/examples/agent-consumer/asset-consistency/action-sequence/verifier-evidence/walk.03.json +26 -0
- package/examples/agent-consumer/asset-planning/pixellab-hero-256x448-request.json +43 -0
- package/examples/agent-consumer/devlab-live-sprite/README.md +30 -0
- package/examples/agent-consumer/devlab-live-sprite/devlab-runtime.json +61 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/idle-00.png +0 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/idle-01.png +0 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/idle-02.png +0 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/reverse-00.png +0 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/reverse-01.png +0 -0
- package/examples/agent-consumer/devlab-live-sprite/frames/reverse-02.png +0 -0
- package/examples/agent-consumer/frame-generation-lock/hero-walk-lock.json +97 -0
- package/package.json +23 -9
- package/references/multi-frame-asset-generation.md +144 -0
- package/schemas/action-separation-verifier-evidence.schema.json +43 -0
- package/schemas/action-sequence-manifest.schema.json +48 -0
- package/schemas/artifact-adapter-registry.schema.json +1 -1
- package/schemas/asset-adaptation.schema.json +21 -0
- package/schemas/asset-generation-plan.schema.json +77 -0
- package/schemas/asset-generation-request.schema.json +104 -0
- package/schemas/frame-envelope.schema.json +62 -0
- package/schemas/frame-generation-lock.schema.json +189 -0
- package/scripts/action-separation.py +410 -0
- package/scripts/asset-adapt.mjs +92 -0
- package/scripts/asset-generation-plan.py +613 -0
- package/scripts/browser_review_consistency.py +64 -0
- package/scripts/fetch-project-corpus.py +91 -0
- package/scripts/frame-generation-lock.py +358 -0
- package/scripts/frame-set-preflight.py +264 -0
- package/scripts/package-consumer-smoke.mjs +20 -0
- package/scripts/quality-gate.py +7 -0
- package/scripts/release-verify.py +25 -0
- package/scripts/report-contract.py +1 -1
- package/scripts/report.py +19 -4
- package/scripts/resolve-task-bundle.py +11 -3
- package/scripts/review-hook.py +51 -2
- package/scripts/skill-doctor.py +42 -0
- package/src/output/browser-review-smoke/browser-review.json +6 -6
- package/tests/scripts/run_tests.py +45 -2
- package/tests/scripts/test_asset_adapt.py +47 -0
- package/tests/scripts/test_asset_consistency.py +77 -4
- package/tests/scripts/test_asset_generation_plan.py +250 -0
- package/tests/scripts/test_attestation.py +24 -8
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail-closed validation for action-scoped isolated frame sequences.
|
|
3
|
+
|
|
4
|
+
The generator may create one PNG per frame, but this contract keeps every frame
|
|
5
|
+
bound to one immutable sequence/action identity and requires an independent
|
|
6
|
+
verifier result against explicitly forbidden competitor actions. It never moves
|
|
7
|
+
files, regenerates assets, grants provenance or grants human approval.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{1,95}$")
|
|
22
|
+
FRAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{1,63}$")
|
|
23
|
+
SHA_RE = re.compile(r"^[a-f0-9]{64}$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def error(code: str, message: str, path: str = "") -> dict[str, str]:
|
|
27
|
+
return {"severity": "error", "code": code, "message": message, "path": path}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def inside(root: Path, value: str) -> tuple[Path | None, dict[str, str] | None]:
|
|
31
|
+
if not isinstance(value, str) or not value.strip() or Path(value).is_absolute():
|
|
32
|
+
return None, error("path_escape", f"path must be a non-empty relative path: {value!r}", value if isinstance(value, str) else "")
|
|
33
|
+
candidate = (root / value).resolve()
|
|
34
|
+
try:
|
|
35
|
+
candidate.relative_to(root.resolve())
|
|
36
|
+
except ValueError:
|
|
37
|
+
return None, error("path_escape", f"path escapes action manifest root: {value}", value)
|
|
38
|
+
return candidate, None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_json(path: Path) -> tuple[dict[str, Any] | None, list[dict[str, str]]]:
|
|
42
|
+
try:
|
|
43
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
44
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
45
|
+
return None, [error("invalid_input", str(exc), str(path))]
|
|
46
|
+
if not isinstance(value, dict):
|
|
47
|
+
return None, [error("invalid_document", "document root must be an object", str(path))]
|
|
48
|
+
return value, []
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def sha256(path: Path) -> str | None:
|
|
52
|
+
try:
|
|
53
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
54
|
+
except OSError:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def canonical(value: Any) -> bytes:
|
|
59
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def result_payload(verifier: dict[str, Any]) -> dict[str, Any]:
|
|
63
|
+
return {key: verifier.get(key) for key in ("expected_action", "top_competitor", "margin", "threshold", "status", "method")}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def result_hash(verifier: dict[str, Any]) -> str:
|
|
67
|
+
return hashlib.sha256(canonical(result_payload(verifier))).hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def action_ids(document: dict[str, Any]) -> tuple[str, list[str]]:
|
|
71
|
+
expected = str(document.get("action_id", ""))
|
|
72
|
+
forbidden = document.get("forbidden_action_ids")
|
|
73
|
+
return expected, [str(item) for item in forbidden] if isinstance(forbidden, list) else []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_manifest(document: dict[str, Any], root: Path) -> dict[str, Any]:
|
|
77
|
+
errors: list[dict[str, str]] = []
|
|
78
|
+
if document.get("schema_version") != "0.2":
|
|
79
|
+
errors.append(error("schema_version", "action sequence manifest schema_version must be 0.2", "schema_version"))
|
|
80
|
+
for key in ("sequence_id", "asset_identity", "action_id", "identity_lock_sha256", "generator_agent_id", "forbidden_action_ids", "actions", "frames"):
|
|
81
|
+
if key not in document:
|
|
82
|
+
errors.append(error("missing_field", f"required field is missing: {key}", key))
|
|
83
|
+
sequence_id = str(document.get("sequence_id", ""))
|
|
84
|
+
action_id = str(document.get("action_id", ""))
|
|
85
|
+
if not ID_RE.fullmatch(sequence_id):
|
|
86
|
+
errors.append(error("invalid_sequence_id", "sequence_id must use lowercase safe identifier characters", "sequence_id"))
|
|
87
|
+
if not FRAME_RE.fullmatch(action_id):
|
|
88
|
+
errors.append(error("invalid_action_id", "action_id must use lowercase safe identifier characters", "action_id"))
|
|
89
|
+
generator_agent_id = str(document.get("generator_agent_id", ""))
|
|
90
|
+
if not FRAME_RE.fullmatch(generator_agent_id):
|
|
91
|
+
errors.append(error("invalid_generator_agent_id", "generator_agent_id must use lowercase safe identifier characters", "generator_agent_id"))
|
|
92
|
+
lock_hash = str(document.get("identity_lock_sha256", ""))
|
|
93
|
+
if not SHA_RE.fullmatch(lock_hash):
|
|
94
|
+
errors.append(error("invalid_identity_lock_sha256", "identity_lock_sha256 must be 64 lowercase hex characters", "identity_lock_sha256"))
|
|
95
|
+
|
|
96
|
+
forbidden_value = document.get("forbidden_action_ids")
|
|
97
|
+
forbidden = [str(item) for item in forbidden_value] if isinstance(forbidden_value, list) else []
|
|
98
|
+
if not forbidden_value or any(not FRAME_RE.fullmatch(item) for item in forbidden):
|
|
99
|
+
errors.append(error("invalid_forbidden_actions", "forbidden_action_ids must be a non-empty safe identifier array", "forbidden_action_ids"))
|
|
100
|
+
if action_id in forbidden:
|
|
101
|
+
errors.append(error("expected_action_forbidden", "action_id must not also be forbidden", "forbidden_action_ids"))
|
|
102
|
+
if len(set(forbidden)) != len(forbidden):
|
|
103
|
+
errors.append(error("duplicate_forbidden_action", "forbidden_action_ids must be unique", "forbidden_action_ids"))
|
|
104
|
+
|
|
105
|
+
actions_value = document.get("actions")
|
|
106
|
+
actions = actions_value if isinstance(actions_value, list) else []
|
|
107
|
+
declared_actions: set[str] = set()
|
|
108
|
+
for index, item in enumerate(actions):
|
|
109
|
+
prefix = f"actions[{index}]"
|
|
110
|
+
if not isinstance(item, dict):
|
|
111
|
+
errors.append(error("invalid_action", "action entry must be an object", prefix))
|
|
112
|
+
continue
|
|
113
|
+
declared = str(item.get("action_id", ""))
|
|
114
|
+
if not FRAME_RE.fullmatch(declared):
|
|
115
|
+
errors.append(error("invalid_action_id", "action_id must use lowercase safe identifier characters", f"{prefix}.action_id"))
|
|
116
|
+
if declared in declared_actions:
|
|
117
|
+
errors.append(error("duplicate_action", f"duplicate action declaration: {declared}", f"{prefix}.action_id"))
|
|
118
|
+
declared_actions.add(declared)
|
|
119
|
+
for cue_key in ("positive_cues", "negative_cues"):
|
|
120
|
+
cues = item.get(cue_key)
|
|
121
|
+
if not isinstance(cues, list) or not cues or any(not isinstance(cue, str) or not cue.strip() for cue in cues):
|
|
122
|
+
errors.append(error("invalid_action_cues", f"{cue_key} must be a non-empty string array", f"{prefix}.{cue_key}"))
|
|
123
|
+
if action_id not in declared_actions:
|
|
124
|
+
errors.append(error("missing_expected_action", "actions must declare the expected action_id", "actions"))
|
|
125
|
+
for competitor in forbidden:
|
|
126
|
+
if competitor not in declared_actions:
|
|
127
|
+
errors.append(error("missing_competitor_action", f"forbidden competitor is not declared: {competitor}", "actions"))
|
|
128
|
+
|
|
129
|
+
frames_value = document.get("frames")
|
|
130
|
+
frames = frames_value if isinstance(frames_value, list) else []
|
|
131
|
+
if len(frames) < 2:
|
|
132
|
+
errors.append(error("insufficient_frames", "an action sequence requires at least two frames", "frames"))
|
|
133
|
+
seen_indexes: set[int] = set()
|
|
134
|
+
seen_frame_ids: set[str] = set()
|
|
135
|
+
seen_images: set[str] = set()
|
|
136
|
+
expected_envelopes: list[dict[str, Any]] = []
|
|
137
|
+
for index, frame in enumerate(frames):
|
|
138
|
+
prefix = f"frames[{index}]"
|
|
139
|
+
if not isinstance(frame, dict):
|
|
140
|
+
errors.append(error("invalid_frame", "frame entry must be an object", prefix))
|
|
141
|
+
continue
|
|
142
|
+
try:
|
|
143
|
+
frame_index = int(frame.get("frame_index"))
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
frame_index = -1
|
|
146
|
+
if frame_index < 0 or frame_index in seen_indexes:
|
|
147
|
+
errors.append(error("invalid_frame_index", "frame_index must be unique and non-negative", f"{prefix}.frame_index"))
|
|
148
|
+
seen_indexes.add(frame_index)
|
|
149
|
+
frame_id = str(frame.get("frame_id", ""))
|
|
150
|
+
if not FRAME_RE.fullmatch(frame_id):
|
|
151
|
+
errors.append(error("invalid_frame_id", "frame_id must use lowercase safe identifier characters", f"{prefix}.frame_id"))
|
|
152
|
+
if frame_id in seen_frame_ids:
|
|
153
|
+
errors.append(error("duplicate_frame_id", f"duplicate frame_id: {frame_id}", f"{prefix}.frame_id"))
|
|
154
|
+
seen_frame_ids.add(frame_id)
|
|
155
|
+
image = str(frame.get("image", ""))
|
|
156
|
+
envelope = str(frame.get("envelope", ""))
|
|
157
|
+
for field, value in (("image", image), ("envelope", envelope)):
|
|
158
|
+
_, path_error = inside(root, value)
|
|
159
|
+
if path_error:
|
|
160
|
+
path_error["path"] = f"{prefix}.{field}"
|
|
161
|
+
errors.append(path_error)
|
|
162
|
+
if image in seen_images:
|
|
163
|
+
errors.append(error("duplicate_frame_image", f"multiple frames reference the same image: {image}", f"{prefix}.image"))
|
|
164
|
+
seen_images.add(image)
|
|
165
|
+
expected_envelopes.append({"frame_index": frame_index, "frame_id": frame_id, "image": image, "envelope": envelope, "path": prefix})
|
|
166
|
+
|
|
167
|
+
if seen_indexes and seen_indexes != set(range(len(frames))):
|
|
168
|
+
errors.append(error("frame_order_gap", "frame_index values must be contiguous from zero", "frames"))
|
|
169
|
+
|
|
170
|
+
envelope_count = 0
|
|
171
|
+
passing_count = 0
|
|
172
|
+
for item in expected_envelopes:
|
|
173
|
+
envelope_path, path_error = inside(root, str(item["envelope"]))
|
|
174
|
+
if path_error or envelope_path is None:
|
|
175
|
+
continue
|
|
176
|
+
envelope, envelope_errors = read_json(envelope_path)
|
|
177
|
+
errors.extend({**entry, "path": f"{item['path']}.envelope"} for entry in envelope_errors)
|
|
178
|
+
if envelope is None:
|
|
179
|
+
continue
|
|
180
|
+
envelope_count += 1
|
|
181
|
+
checks = {
|
|
182
|
+
"sequence_id": (envelope.get("sequence_id"), sequence_id),
|
|
183
|
+
"action_id": (envelope.get("action_id"), action_id),
|
|
184
|
+
"frame_id": (envelope.get("frame_id"), item["frame_id"]),
|
|
185
|
+
"frame_index": (envelope.get("frame_index"), item["frame_index"]),
|
|
186
|
+
"identity_lock_sha256": (envelope.get("identity_lock_sha256"), lock_hash),
|
|
187
|
+
"image": (envelope.get("image"), item["image"]),
|
|
188
|
+
}
|
|
189
|
+
for field, (actual, expected) in checks.items():
|
|
190
|
+
if actual != expected:
|
|
191
|
+
errors.append(error("envelope_binding_mismatch", f"envelope {field} does not match manifest", f"{item['path']}.envelope.{field}"))
|
|
192
|
+
image_path, image_error = inside(root, str(item["image"]))
|
|
193
|
+
if image_error or image_path is None:
|
|
194
|
+
continue
|
|
195
|
+
actual_hash = sha256(image_path)
|
|
196
|
+
if actual_hash is None:
|
|
197
|
+
errors.append(error("image_unreadable", f"cannot read image: {item['image']}", f"{item['path']}.image"))
|
|
198
|
+
elif envelope.get("image_sha256") != actual_hash:
|
|
199
|
+
errors.append(error("image_sha256_mismatch", "frame envelope image hash does not match file bytes", f"{item['path']}.envelope.image_sha256"))
|
|
200
|
+
if envelope.get("generator_agent_id") != generator_agent_id:
|
|
201
|
+
errors.append(error("generator_binding_mismatch", "envelope generator_agent_id does not match manifest", f"{item['path']}.envelope.generator_agent_id"))
|
|
202
|
+
if envelope.get("approval") is not False:
|
|
203
|
+
errors.append(error("approval_boundary", "frame envelope validation must preserve approval=false", f"{item['path']}.envelope.approval"))
|
|
204
|
+
verifier = envelope.get("verifier") if isinstance(envelope.get("verifier"), dict) else {}
|
|
205
|
+
verifier_id = verifier.get("verifier_id")
|
|
206
|
+
if not isinstance(verifier_id, str) or not FRAME_RE.fullmatch(verifier_id):
|
|
207
|
+
errors.append(error("invalid_verifier_id", "verifier_id must use lowercase safe identifier characters", f"{item['path']}.envelope.verifier.verifier_id"))
|
|
208
|
+
elif verifier_id == generator_agent_id:
|
|
209
|
+
errors.append(error("non_independent_verifier", "verifier_id must differ from generator_agent_id", f"{item['path']}.envelope.verifier.verifier_id"))
|
|
210
|
+
verification_mode = verifier.get("verification_mode")
|
|
211
|
+
verifier_path = f"{item['path']}.envelope.verifier"
|
|
212
|
+
if verification_mode not in {"declared", "independently_bound"}:
|
|
213
|
+
errors.append(error("invalid_verification_mode", "verification_mode must be declared or independently_bound", f"{verifier_path}.verification_mode"))
|
|
214
|
+
elif verification_mode == "declared":
|
|
215
|
+
errors.append(error("verifier_evidence_declared", "declared verifier evidence cannot be treated as independently verified", f"{verifier_path}.verification_mode"))
|
|
216
|
+
else:
|
|
217
|
+
evidence = verifier.get("evidence") if isinstance(verifier.get("evidence"), dict) else {}
|
|
218
|
+
evidence_value = evidence.get("artifact")
|
|
219
|
+
if not isinstance(evidence_value, str) or not evidence_value.strip():
|
|
220
|
+
errors.append(error("missing_verifier_provenance", "independently_bound evidence must include an artifact path", f"{verifier_path}.evidence.artifact"))
|
|
221
|
+
evidence_path, evidence_path_error = None, None
|
|
222
|
+
else:
|
|
223
|
+
evidence_path, evidence_path_error = inside(root, evidence_value)
|
|
224
|
+
if evidence_path_error or evidence_path is None:
|
|
225
|
+
if not (not isinstance(evidence_value, str) or not evidence_value.strip()):
|
|
226
|
+
errors.append(error("missing_verifier_provenance", "independently_bound evidence must reference a safe artifact path", f"{verifier_path}.evidence.artifact"))
|
|
227
|
+
else:
|
|
228
|
+
evidence_doc, evidence_errors = read_json(evidence_path)
|
|
229
|
+
errors.extend({**entry, "path": f"{verifier_path}.evidence.artifact"} for entry in evidence_errors)
|
|
230
|
+
actual_evidence_hash = sha256(evidence_path)
|
|
231
|
+
if actual_evidence_hash is None or evidence.get("artifact_sha256") != actual_evidence_hash:
|
|
232
|
+
errors.append(error("verifier_evidence_hash_mismatch", "verifier evidence artifact hash does not match file bytes", f"{verifier_path}.evidence.artifact_sha256"))
|
|
233
|
+
if evidence_doc is not None:
|
|
234
|
+
provenance = evidence_doc.get("provenance") if isinstance(evidence_doc.get("provenance"), dict) else {}
|
|
235
|
+
if provenance.get("kind") != "separate_verifier_artifact":
|
|
236
|
+
errors.append(error("missing_verifier_provenance", "verifier evidence must declare separate_verifier_artifact provenance", f"{verifier_path}.evidence.artifact.provenance.kind"))
|
|
237
|
+
producer_id = provenance.get("producer_id")
|
|
238
|
+
if not isinstance(producer_id, str) or not FRAME_RE.fullmatch(producer_id) or producer_id == generator_agent_id:
|
|
239
|
+
errors.append(error("non_independent_verifier", "verifier evidence producer_id must be valid and differ from generator_agent_id", f"{verifier_path}.evidence.artifact.provenance.producer_id"))
|
|
240
|
+
expected_evidence_binding = {
|
|
241
|
+
"sequence_id": sequence_id,
|
|
242
|
+
"action_id": action_id,
|
|
243
|
+
"frame_id": item["frame_id"],
|
|
244
|
+
"frame_index": item["frame_index"],
|
|
245
|
+
"image_sha256": envelope.get("image_sha256"),
|
|
246
|
+
"identity_lock_sha256": lock_hash,
|
|
247
|
+
"generator_agent_id": generator_agent_id,
|
|
248
|
+
"verifier_id": verifier_id,
|
|
249
|
+
}
|
|
250
|
+
for field, expected in expected_evidence_binding.items():
|
|
251
|
+
if evidence_doc.get(field) != expected:
|
|
252
|
+
errors.append(error("verifier_evidence_binding_mismatch", f"verifier evidence {field} does not match envelope", f"{verifier_path}.evidence.artifact.{field}"))
|
|
253
|
+
evidence_result = evidence_doc.get("result") if isinstance(evidence_doc.get("result"), dict) else None
|
|
254
|
+
current_result = result_payload(verifier)
|
|
255
|
+
if evidence_result != current_result:
|
|
256
|
+
errors.append(error("verifier_result_mismatch", "verifier result does not match independently bound evidence", f"{verifier_path}.evidence.result_sha256"))
|
|
257
|
+
expected_result_hash = result_hash(verifier)
|
|
258
|
+
if evidence.get("result_sha256") != expected_result_hash or evidence_doc.get("result_sha256") != expected_result_hash:
|
|
259
|
+
errors.append(error("verifier_result_hash_mismatch", "verifier result hash does not match canonical result", f"{verifier_path}.evidence.result_sha256"))
|
|
260
|
+
expected_action = verifier.get("expected_action")
|
|
261
|
+
competitor = verifier.get("top_competitor")
|
|
262
|
+
try:
|
|
263
|
+
margin = float(verifier.get("margin"))
|
|
264
|
+
threshold = float(verifier.get("threshold"))
|
|
265
|
+
except (TypeError, ValueError):
|
|
266
|
+
margin, threshold = -1.0, 0.0
|
|
267
|
+
if expected_action != action_id:
|
|
268
|
+
errors.append(error("action_verifier_mismatch", "verifier expected_action does not match manifest action", f"{item['path']}.envelope.verifier.expected_action"))
|
|
269
|
+
if competitor not in forbidden:
|
|
270
|
+
errors.append(error("undeclared_competitor", "verifier top_competitor must be one of forbidden_action_ids", f"{item['path']}.envelope.verifier.top_competitor"))
|
|
271
|
+
if not (margin >= threshold >= 0):
|
|
272
|
+
errors.append(error("action_confidence_margin", "action separation margin is below its declared threshold", f"{item['path']}.envelope.verifier.margin"))
|
|
273
|
+
if verifier.get("status") != "pass":
|
|
274
|
+
errors.append(error("action_verification_required", "ambiguous or failed action verification must remain quarantined", f"{item['path']}.envelope.verifier.status"))
|
|
275
|
+
if verification_mode == "independently_bound" and expected_action == action_id and competitor in forbidden and margin >= threshold >= 0 and verifier.get("status") == "pass":
|
|
276
|
+
passing_count += 1
|
|
277
|
+
|
|
278
|
+
if document.get("approval") is not False:
|
|
279
|
+
errors.append(error("approval_boundary", "action sequence validation must preserve approval=false", "approval"))
|
|
280
|
+
if document.get("status") not in {"review_required", "quarantined"}:
|
|
281
|
+
errors.append(error("invalid_status", "action sequence status must remain review_required or quarantined", "status"))
|
|
282
|
+
|
|
283
|
+
canonical = json.dumps(document, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
284
|
+
return {
|
|
285
|
+
"contract": "action_sequence_manifest",
|
|
286
|
+
"ready": not errors,
|
|
287
|
+
"errors": errors,
|
|
288
|
+
"warnings": [],
|
|
289
|
+
"metrics": {
|
|
290
|
+
"sequence_id": sequence_id,
|
|
291
|
+
"action_id": action_id,
|
|
292
|
+
"frame_count": len(frames),
|
|
293
|
+
"envelope_count": envelope_count,
|
|
294
|
+
"passing_action_verifications": passing_count,
|
|
295
|
+
"independently_bound_verifications": passing_count,
|
|
296
|
+
"manifest_sha256": hashlib.sha256(canonical).hexdigest(),
|
|
297
|
+
"approval": False,
|
|
298
|
+
},
|
|
299
|
+
"approval": False,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def build_envelope(
|
|
304
|
+
document: dict[str, Any],
|
|
305
|
+
root: Path,
|
|
306
|
+
frame_id: str,
|
|
307
|
+
expected_action: str,
|
|
308
|
+
top_competitor: str,
|
|
309
|
+
margin: float,
|
|
310
|
+
threshold: float,
|
|
311
|
+
method: str,
|
|
312
|
+
) -> dict[str, Any]:
|
|
313
|
+
frame = next((item for item in document.get("frames", []) if isinstance(item, dict) and item.get("frame_id") == frame_id), None)
|
|
314
|
+
if frame is None:
|
|
315
|
+
return {"contract": "frame_envelope", "ready": False, "errors": [error("unknown_frame", f"frame_id not found in manifest: {frame_id}", "frame_id")], "warnings": [], "approval": False}
|
|
316
|
+
image_value = str(frame.get("image", ""))
|
|
317
|
+
image_path, path_error = inside(root, image_value)
|
|
318
|
+
if path_error or image_path is None:
|
|
319
|
+
return {"contract": "frame_envelope", "ready": False, "errors": [path_error or error("invalid_image", "image path is invalid", "image")], "warnings": [], "approval": False}
|
|
320
|
+
image_hash = sha256(image_path)
|
|
321
|
+
if image_hash is None:
|
|
322
|
+
return {"contract": "frame_envelope", "ready": False, "errors": [error("image_unreadable", f"cannot read image: {image_value}", "image")], "warnings": [], "approval": False}
|
|
323
|
+
expected = str(document.get("action_id", ""))
|
|
324
|
+
forbidden = document.get("forbidden_action_ids", [])
|
|
325
|
+
criteria_pass = expected_action == expected and top_competitor in forbidden and margin >= threshold >= 0
|
|
326
|
+
status = "quarantined"
|
|
327
|
+
payload = {
|
|
328
|
+
"schema_version": "0.2",
|
|
329
|
+
"sequence_id": document.get("sequence_id"),
|
|
330
|
+
"action_id": expected,
|
|
331
|
+
"frame_id": frame_id,
|
|
332
|
+
"frame_index": frame.get("frame_index"),
|
|
333
|
+
"image": image_value,
|
|
334
|
+
"image_sha256": image_hash,
|
|
335
|
+
"identity_lock_sha256": document.get("identity_lock_sha256"),
|
|
336
|
+
"generator_agent_id": document.get("generator_agent_id"),
|
|
337
|
+
"verifier": {
|
|
338
|
+
"verifier_id": "motionloom-separation-verifier-v1",
|
|
339
|
+
"verification_mode": "declared",
|
|
340
|
+
"expected_action": expected_action,
|
|
341
|
+
"top_competitor": top_competitor,
|
|
342
|
+
"margin": margin,
|
|
343
|
+
"threshold": threshold,
|
|
344
|
+
"status": status,
|
|
345
|
+
"method": method,
|
|
346
|
+
},
|
|
347
|
+
"approval": False,
|
|
348
|
+
}
|
|
349
|
+
reason = "generated envelope contains declared verifier fields only; an independently bound evidence artifact is required"
|
|
350
|
+
if not criteria_pass:
|
|
351
|
+
reason = "generated verifier criteria do not pass; envelope remains quarantined"
|
|
352
|
+
return {"contract": "frame_envelope", "ready": False, "errors": [error("verifier_evidence_declared", reason, "verifier")], "warnings": [], "envelope": payload, "approval": False}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def emit(result: dict[str, Any], as_json: bool) -> None:
|
|
356
|
+
if as_json:
|
|
357
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
358
|
+
return
|
|
359
|
+
print(f"action separation: {'PASS' if result.get('ready') else 'QUARANTINED'}")
|
|
360
|
+
for item in result.get("errors", []):
|
|
361
|
+
print(f"ERROR {item.get('code')}: {item.get('message')}")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def main(argv: list[str] | None = None) -> int:
|
|
365
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
366
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
367
|
+
command = sub.add_parser("validate")
|
|
368
|
+
command.add_argument("--input", required=True, help="action-sequence manifest JSON")
|
|
369
|
+
command.add_argument("--root", default=".", help="root used to resolve manifest paths")
|
|
370
|
+
command.add_argument("--json", action="store_true", dest="as_json")
|
|
371
|
+
envelope_command = sub.add_parser("envelope")
|
|
372
|
+
envelope_command.add_argument("--manifest", required=True, help="action-sequence manifest JSON")
|
|
373
|
+
envelope_command.add_argument("--root", default=".", help="root used to resolve manifest paths")
|
|
374
|
+
envelope_command.add_argument("--frame-id", required=True)
|
|
375
|
+
envelope_command.add_argument("--expected-action", required=True)
|
|
376
|
+
envelope_command.add_argument("--top-competitor", required=True)
|
|
377
|
+
envelope_command.add_argument("--margin", type=float, required=True)
|
|
378
|
+
envelope_command.add_argument("--threshold", type=float, default=0.2)
|
|
379
|
+
envelope_command.add_argument("--method", default="independent-action-rubric-v1")
|
|
380
|
+
envelope_command.add_argument("--output")
|
|
381
|
+
envelope_command.add_argument("--json", action="store_true", dest="as_json")
|
|
382
|
+
args = parser.parse_args(argv)
|
|
383
|
+
input_path = Path(args.input if args.command == "validate" else args.manifest)
|
|
384
|
+
document, load_errors = read_json(input_path)
|
|
385
|
+
result = {"contract": "action_sequence_manifest", "ready": False, "errors": load_errors, "warnings": [], "metrics": {}, "approval": False}
|
|
386
|
+
if document is not None:
|
|
387
|
+
if args.command == "validate":
|
|
388
|
+
result = validate_manifest(document, Path(args.root).resolve())
|
|
389
|
+
else:
|
|
390
|
+
result = build_envelope(
|
|
391
|
+
document,
|
|
392
|
+
Path(args.root).resolve(),
|
|
393
|
+
args.frame_id,
|
|
394
|
+
args.expected_action,
|
|
395
|
+
args.top_competitor,
|
|
396
|
+
args.margin,
|
|
397
|
+
args.threshold,
|
|
398
|
+
args.method,
|
|
399
|
+
)
|
|
400
|
+
if args.output and result.get("envelope"):
|
|
401
|
+
output_path = Path(args.output).resolve()
|
|
402
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
403
|
+
output_path.write_text(json.dumps(result["envelope"], indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
404
|
+
result["output"] = str(output_path)
|
|
405
|
+
emit(result, args.as_json)
|
|
406
|
+
return 0 if result.get("ready") else 1
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
if __name__ == "__main__":
|
|
410
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic target-canvas adaptation for provider outputs.
|
|
4
|
+
* This command never calls a provider and never crops or stretches silently.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
|
10
|
+
|
|
11
|
+
function usage() {
|
|
12
|
+
console.error("usage: motionloom asset-adapt pad --input source.png --output target.png --width W --height H [--scale N] [--anchor center|footline] [--report report.json] [--json]");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function arg(name, fallback = undefined) {
|
|
16
|
+
const index = process.argv.indexOf(name);
|
|
17
|
+
return index >= 0 ? process.argv[index + 1] : fallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function required(name) {
|
|
21
|
+
const value = arg(name);
|
|
22
|
+
if (!value) throw new Error(`${name} is required`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sha256(file) {
|
|
27
|
+
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function inside(root, file) {
|
|
31
|
+
const relative = path.relative(root, file);
|
|
32
|
+
return relative && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function main() {
|
|
36
|
+
const operation = process.argv[2];
|
|
37
|
+
if (operation !== "pad") {
|
|
38
|
+
usage();
|
|
39
|
+
process.exitCode = 2;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const input = path.resolve(required("--input"));
|
|
43
|
+
const output = path.resolve(required("--output"));
|
|
44
|
+
const width = Number(required("--width"));
|
|
45
|
+
const height = Number(required("--height"));
|
|
46
|
+
const scale = Number(arg("--scale", "1"));
|
|
47
|
+
const anchor = arg("--anchor", "footline");
|
|
48
|
+
const reportPath = arg("--report");
|
|
49
|
+
if (!fs.existsSync(input)) throw new Error(`input not found: ${input}`);
|
|
50
|
+
if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) throw new Error("target width/height must be positive integers");
|
|
51
|
+
if (!Number.isInteger(scale) || scale < 1) throw new Error("scale must be a positive integer");
|
|
52
|
+
if (!["center", "footline"].includes(anchor)) throw new Error("anchor must be center or footline");
|
|
53
|
+
if (input === output) throw new Error("input and output must be different files");
|
|
54
|
+
const image = await loadImage(input);
|
|
55
|
+
const scaledWidth = image.width * scale;
|
|
56
|
+
const scaledHeight = image.height * scale;
|
|
57
|
+
if (scaledWidth > width || scaledHeight > height) {
|
|
58
|
+
throw new Error(`source ${image.width}x${image.height} at integer scale ${scale} exceeds target ${width}x${height}; crop is forbidden`);
|
|
59
|
+
}
|
|
60
|
+
const x = Math.floor((width - scaledWidth) / 2);
|
|
61
|
+
const y = anchor === "footline" ? height - scaledHeight : Math.floor((height - scaledHeight) / 2);
|
|
62
|
+
const canvas = createCanvas(width, height);
|
|
63
|
+
const context = canvas.getContext("2d");
|
|
64
|
+
context.clearRect(0, 0, width, height);
|
|
65
|
+
context.imageSmoothingEnabled = false;
|
|
66
|
+
context.drawImage(image, x, y, scaledWidth, scaledHeight);
|
|
67
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
68
|
+
fs.writeFileSync(output, await canvas.encode("png"));
|
|
69
|
+
const report = {
|
|
70
|
+
contract: "motionloom-asset-adaptation",
|
|
71
|
+
schema_version: "0.1",
|
|
72
|
+
operation: "pad",
|
|
73
|
+
status: "built",
|
|
74
|
+
approval: false,
|
|
75
|
+
production_approved: false,
|
|
76
|
+
source: { path: input, sha256: sha256(input), canvas: [image.width, image.height] },
|
|
77
|
+
output: { path: output, sha256: sha256(output), canvas: [width, height] },
|
|
78
|
+
transform: { scale, x, y, anchor, crop: false, stretch: false, interpolation: "nearest-neighbour" },
|
|
79
|
+
note: "Deterministic transparent canvas adaptation; visual and frame/action contracts remain required.",
|
|
80
|
+
};
|
|
81
|
+
if (reportPath) {
|
|
82
|
+
const reportAbsolute = path.resolve(reportPath);
|
|
83
|
+
fs.mkdirSync(path.dirname(reportAbsolute), { recursive: true });
|
|
84
|
+
fs.writeFileSync(reportAbsolute, JSON.stringify(report, null, 2) + "\n");
|
|
85
|
+
}
|
|
86
|
+
console.log(JSON.stringify(report, null, 2));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
main().catch((error) => {
|
|
90
|
+
console.error(`asset-adapt blocked: ${error.message}`);
|
|
91
|
+
process.exitCode = 2;
|
|
92
|
+
});
|