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.
Files changed (46) hide show
  1. package/.agents/skills/motionloom/SKILL.md +14 -0
  2. package/.claude/skills/motionloom.md +5 -0
  3. package/.codex/skills/motionloom.md +11 -0
  4. package/AGENTS.md +17 -0
  5. package/CHANGELOG.md +51 -0
  6. package/README.md +60 -15
  7. package/ROADMAP.md +11 -5
  8. package/SECURITY.md +3 -2
  9. package/SKILL.md +40 -5
  10. package/agent-card.json +42 -4
  11. package/agent-surfaces.json +86 -0
  12. package/bin/motionloom.mjs +26 -3
  13. package/docs/AGENT-INTEGRATION.md +60 -0
  14. package/docs/CHECKLIST.md +7 -1
  15. package/docs/STATUS.md +2 -2
  16. package/docs/audits/ci-replay-remediation-2026-08-13.md +33 -0
  17. package/docs/releases/2.2.0.md +35 -0
  18. package/docs/releases/2.3.0.md +33 -0
  19. package/docs/releases/npm-publish-from-workstation.md +6 -6
  20. package/examples/agent-consumer/README.md +18 -0
  21. package/examples/agent-consumer/ai-generated-pilot/hero-male.json +10 -0
  22. package/examples/agent-consumer/ai-generated-pilot-provenance.json +55 -0
  23. package/examples/agent-consumer/fixture-manifest.json +82 -0
  24. package/package.json +31 -7
  25. package/references/agent-interoperability.md +40 -0
  26. package/references/intelligence-core.md +12 -2
  27. package/schemas/agent-surfaces.schema.json +78 -0
  28. package/schemas/asset-provenance.schema.json +183 -0
  29. package/schemas/remediation-history.schema.json +23 -0
  30. package/schemas/scene-manifest.schema.json +2 -0
  31. package/schemas/visual-truth.schema.json +80 -0
  32. package/scripts/asset-provenance.py +390 -0
  33. package/scripts/devlab.py +1 -1
  34. package/scripts/discovery.py +257 -0
  35. package/scripts/docs-audit.py +30 -2
  36. package/scripts/pr.py +3 -0
  37. package/scripts/quality-gate.py +81 -3
  38. package/scripts/remediation-learning.py +326 -0
  39. package/scripts/report.py +66 -0
  40. package/scripts/setup.mjs +472 -0
  41. package/scripts/skill-doctor.py +2 -1
  42. package/scripts/visual-truth.py +310 -0
  43. package/src/output/browser-review-smoke/asset-provenance.json +77 -0
  44. package/src/output/browser-review-smoke/manifest.json +2 -0
  45. package/src/output/browser-review-smoke/visual-truth.json +68 -0
  46. package/tests/scripts/run_tests.py +83 -0
@@ -0,0 +1,390 @@
1
+ """Validate and classify asset-level provenance without granting approval.
2
+
3
+ The contract is intentionally fail-closed. An Agent may declare how an asset
4
+ was produced and may prove runtime readiness, but it cannot manufacture human
5
+ authority or production approval. The script uses pathlib and JSON only so
6
+ the same entrypoint works on Ubuntu, macOS and Windows.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import hashlib
13
+ import json
14
+ import re
15
+ import sys
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ ROOT = Path(__file__).resolve().parents[1]
22
+ SCHEMA_VERSION = "1.0"
23
+ AUTHORITIES = {
24
+ "ai_generated",
25
+ "ai_assisted",
26
+ "ai_assisted_human_reviewed",
27
+ "artist_authored",
28
+ "unknown",
29
+ }
30
+ READINESS = {
31
+ "blocked",
32
+ "runtime_ready",
33
+ "review_required",
34
+ "production_eligible",
35
+ "production_approved",
36
+ }
37
+ SAFE_RELATIVE = re.compile(r"^(?!/)(?!.*(?:^|/)\.\.(?:/|$)).+")
38
+ SHA256 = re.compile(r"^[a-f0-9]{64}$")
39
+
40
+
41
+ def read_json(path: Path) -> dict[str, Any]:
42
+ try:
43
+ value = json.loads(path.read_text(encoding="utf-8"))
44
+ except (OSError, json.JSONDecodeError) as exc:
45
+ raise ValueError(f"{path}: {exc}") from exc
46
+ if not isinstance(value, dict):
47
+ raise ValueError(f"{path}: provenance document must be a JSON object")
48
+ return value
49
+
50
+
51
+ def is_datetime(value: Any) -> bool:
52
+ if not isinstance(value, str) or not value.strip():
53
+ return False
54
+ try:
55
+ datetime.fromisoformat(value.replace("Z", "+00:00"))
56
+ except ValueError:
57
+ return False
58
+ return True
59
+
60
+
61
+ def is_relative_path(value: Any) -> bool:
62
+ return isinstance(value, str) and bool(SAFE_RELATIVE.fullmatch(value))
63
+
64
+
65
+ def is_sha256(value: Any) -> bool:
66
+ return isinstance(value, str) and bool(SHA256.fullmatch(value))
67
+
68
+
69
+ def _required(data: dict[str, Any], keys: tuple[str, ...], prefix: str, errors: list[str]) -> None:
70
+ for key in keys:
71
+ if key not in data:
72
+ errors.append(f"{prefix}.{key} is required")
73
+
74
+
75
+ def _check_text(data: dict[str, Any], key: str, prefix: str, errors: list[str]) -> None:
76
+ if key in data and (not isinstance(data[key], str) or not data[key].strip()):
77
+ errors.append(f"{prefix}.{key} must be a non-empty string")
78
+
79
+
80
+ def _check_human_review(data: dict[str, Any], errors: list[str]) -> None:
81
+ review = data.get("human_review")
82
+ if not isinstance(review, dict):
83
+ errors.append("human_review is required for human-reviewed readiness")
84
+ return
85
+ _required(review, ("reviewer", "decision", "scope", "reviewed_at", "user_confirmed"), "human_review", errors)
86
+ _check_text(review, "reviewer", "human_review", errors)
87
+ if review.get("decision") not in {"approved", "rejected", "changes_requested"}:
88
+ errors.append("human_review.decision must be approved, rejected or changes_requested")
89
+ if not is_datetime(review.get("reviewed_at")):
90
+ errors.append("human_review.reviewed_at must be an ISO-8601 timestamp")
91
+ if review.get("user_confirmed") is not True:
92
+ errors.append("human_review.user_confirmed must be true; Agent-only review is not sufficient")
93
+
94
+
95
+ def _check_human_attestation(data: dict[str, Any], errors: list[str]) -> None:
96
+ attestation = data.get("human_attestation")
97
+ if not isinstance(attestation, dict):
98
+ errors.append("artist_authored requires human_attestation")
99
+ return
100
+ _required(attestation, ("attestor", "attestor_type", "decision", "attested_at", "user_confirmed"), "human_attestation", errors)
101
+ _check_text(attestation, "attestor", "human_attestation", errors)
102
+ if attestation.get("attestor_type") not in {"artist", "user"}:
103
+ errors.append("human_attestation.attestor_type must be artist or user")
104
+ if attestation.get("decision") != "artist_authored":
105
+ errors.append("human_attestation.decision must be artist_authored")
106
+ if not is_datetime(attestation.get("attested_at")):
107
+ errors.append("human_attestation.attested_at must be an ISO-8601 timestamp")
108
+ if attestation.get("user_confirmed") is not True:
109
+ errors.append("human_attestation.user_confirmed must be true; Agent-only authority is rejected")
110
+
111
+
112
+ def _check_generator(data: dict[str, Any], errors: list[str]) -> None:
113
+ generator = data.get("generator")
114
+ if not isinstance(generator, dict):
115
+ errors.append("AI-origin authority requires generator metadata")
116
+ return
117
+ _required(generator, ("model", "task_id", "source", "generated_at"), "generator", errors)
118
+ for key in ("model", "task_id", "source"):
119
+ _check_text(generator, key, "generator", errors)
120
+ if not is_datetime(generator.get("generated_at")):
121
+ errors.append("generator.generated_at must be an ISO-8601 timestamp")
122
+ if "prompt_hash" in generator and not is_sha256(generator.get("prompt_hash")):
123
+ errors.append("generator.prompt_hash must be a lowercase SHA-256")
124
+
125
+
126
+ def _check_files(data: dict[str, Any], errors: list[str]) -> None:
127
+ files = data.get("files")
128
+ if not isinstance(files, list) or not files:
129
+ errors.append("files must contain at least one asset file")
130
+ return
131
+ for index, item in enumerate(files):
132
+ prefix = f"files[{index}]"
133
+ if not isinstance(item, dict):
134
+ errors.append(f"{prefix} must be an object")
135
+ continue
136
+ _required(item, ("path", "role", "sha256"), prefix, errors)
137
+ if not is_relative_path(item.get("path")):
138
+ errors.append(f"{prefix}.path must be a safe relative path")
139
+ _check_text(item, "role", prefix, errors)
140
+ if not is_sha256(item.get("sha256")):
141
+ errors.append(f"{prefix}.sha256 must be a lowercase SHA-256")
142
+ if "bytes" in item and (not isinstance(item["bytes"], int) or item["bytes"] < 0):
143
+ errors.append(f"{prefix}.bytes must be a non-negative integer")
144
+
145
+
146
+ def _check_chain(data: dict[str, Any], errors: list[str]) -> None:
147
+ chain = data.get("provenance_chain")
148
+ if not isinstance(chain, list) or not chain:
149
+ errors.append("provenance_chain must contain at least one step")
150
+ return
151
+ for index, item in enumerate(chain):
152
+ prefix = f"provenance_chain[{index}]"
153
+ if not isinstance(item, dict):
154
+ errors.append(f"{prefix} must be an object")
155
+ continue
156
+ _required(item, ("step", "actor", "source", "timestamp"), prefix, errors)
157
+ for key in ("step", "actor", "source"):
158
+ _check_text(item, key, prefix, errors)
159
+ if not is_datetime(item.get("timestamp")):
160
+ errors.append(f"{prefix}.timestamp must be an ISO-8601 timestamp")
161
+ if "sha256" in item and not is_sha256(item.get("sha256")):
162
+ errors.append(f"{prefix}.sha256 must be a lowercase SHA-256")
163
+
164
+
165
+ def validate_document(data: dict[str, Any]) -> list[str]:
166
+ errors: list[str] = []
167
+ _required(data, ("schema_version", "provenance_id", "asset", "authority", "readiness", "files", "license", "provenance_chain", "created_at"), "provenance", errors)
168
+ if data.get("schema_version") != SCHEMA_VERSION:
169
+ errors.append(f"schema_version must be {SCHEMA_VERSION}")
170
+ _check_text(data, "provenance_id", "provenance", errors)
171
+ if not is_datetime(data.get("created_at")):
172
+ errors.append("created_at must be an ISO-8601 timestamp")
173
+
174
+ asset = data.get("asset")
175
+ if not isinstance(asset, dict):
176
+ errors.append("asset must be an object")
177
+ else:
178
+ _required(asset, ("id", "path", "type", "framework"), "asset", errors)
179
+ _check_text(asset, "id", "asset", errors)
180
+ if not is_relative_path(asset.get("path")):
181
+ errors.append("asset.path must be a safe relative path")
182
+ for key in ("type", "framework"):
183
+ _check_text(asset, key, "asset", errors)
184
+
185
+ authority = data.get("authority")
186
+ readiness = data.get("readiness")
187
+ if authority not in AUTHORITIES:
188
+ errors.append(f"authority must be one of {sorted(AUTHORITIES)}")
189
+ if readiness not in READINESS:
190
+ errors.append(f"readiness must be one of {sorted(READINESS)}")
191
+
192
+ license_data = data.get("license")
193
+ if not isinstance(license_data, dict):
194
+ errors.append("license must be an object")
195
+ else:
196
+ _required(license_data, ("spdx", "source", "attribution"), "license", errors)
197
+ for key in ("spdx", "source", "attribution"):
198
+ _check_text(license_data, key, "license", errors)
199
+
200
+ _check_files(data, errors)
201
+ _check_chain(data, errors)
202
+ if authority in {"ai_generated", "ai_assisted", "ai_assisted_human_reviewed"}:
203
+ _check_generator(data, errors)
204
+ if authority == "artist_authored":
205
+ _check_human_attestation(data, errors)
206
+
207
+ if authority == "unknown" and readiness != "blocked":
208
+ errors.append("unknown authority is always blocked")
209
+ if authority == "ai_generated" and readiness != "runtime_ready":
210
+ errors.append("ai_generated assets may be runtime_ready only; they are never production eligible")
211
+ if authority == "ai_assisted_human_reviewed" and readiness not in {"review_required", "production_eligible"}:
212
+ errors.append("ai_assisted_human_reviewed assets must remain review_required or pass a later production gate")
213
+ if authority == "ai_assisted_human_reviewed":
214
+ _check_human_review(data, errors)
215
+ if authority == "ai_assisted" and readiness not in {"runtime_ready", "review_required", "production_eligible"}:
216
+ errors.append("ai_assisted assets must be runtime_ready, review_required or production_eligible")
217
+ if authority == "ai_assisted" and readiness == "production_eligible":
218
+ _check_human_review(data, errors)
219
+ if readiness in {"production_eligible", "production_approved"}:
220
+ runtime = data.get("runtime_evidence")
221
+ if not isinstance(runtime, dict) or runtime.get("status") != "pass":
222
+ errors.append("production eligibility requires runtime_evidence.status=pass")
223
+ full_gate = data.get("full_gate")
224
+ if not isinstance(full_gate, dict):
225
+ errors.append("production eligibility requires full_gate evidence")
226
+ else:
227
+ _required(full_gate, ("status", "quality_gate", "visual_truth", "license", "checked_at"), "full_gate", errors)
228
+ if full_gate.get("status") != "pass" or full_gate.get("quality_gate") != "pass" or full_gate.get("visual_truth") != "pass" or full_gate.get("license") != "pass":
229
+ errors.append("full_gate must have pass for status, quality_gate, visual_truth and license")
230
+ if not is_datetime(full_gate.get("checked_at")):
231
+ errors.append("full_gate.checked_at must be an ISO-8601 timestamp")
232
+ if authority in {"ai_assisted", "ai_assisted_human_reviewed"}:
233
+ _check_human_review(data, errors)
234
+ if authority in {"unknown", "ai_generated"}:
235
+ errors.append(f"{authority} cannot be production eligible")
236
+ if readiness == "production_approved":
237
+ approval = data.get("human_approval")
238
+ if not isinstance(approval, dict):
239
+ errors.append("production_approved requires human_approval")
240
+ else:
241
+ _required(approval, ("issued_by", "decision", "approved_at", "user_confirmed"), "human_approval", errors)
242
+ issuer = approval.get("issued_by")
243
+ if not isinstance(issuer, dict) or issuer.get("type") not in {"user", "artist"} or not str(issuer.get("id") or "").strip():
244
+ errors.append("human_approval.issued_by must be a non-empty user or artist identity")
245
+ if approval.get("decision") != "approved":
246
+ errors.append("human_approval.decision must be approved")
247
+ if not is_datetime(approval.get("approved_at")):
248
+ errors.append("human_approval.approved_at must be an ISO-8601 timestamp")
249
+ if approval.get("user_confirmed") is not True:
250
+ errors.append("human_approval.user_confirmed must be true")
251
+ if authority in {"unknown", "ai_generated"}:
252
+ errors.append("unknown and ai_generated assets can never be production_approved")
253
+ if "human_approval" in data and readiness != "production_approved":
254
+ errors.append("human_approval is only valid when readiness is production_approved")
255
+ return errors
256
+
257
+
258
+ def _resolve_inside(base: Path, relative: str) -> Path | None:
259
+ if not is_relative_path(relative):
260
+ return None
261
+ resolved = (base / relative).resolve()
262
+ try:
263
+ resolved.relative_to(base.resolve())
264
+ except ValueError:
265
+ return None
266
+ return resolved
267
+
268
+
269
+ def validate_files(data: dict[str, Any], base: Path) -> list[str]:
270
+ errors: list[str] = []
271
+ for item in data.get("files", []):
272
+ path = _resolve_inside(base, str(item.get("path", "")))
273
+ if path is None or not path.is_file():
274
+ errors.append(f"asset file is missing: {item.get('path', '<unknown>')}")
275
+ continue
276
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
277
+ if digest != item.get("sha256"):
278
+ errors.append(f"asset file SHA-256 mismatch: {item.get('path', '<unknown>')}")
279
+ if "bytes" in item and path.stat().st_size != item.get("bytes"):
280
+ errors.append(f"asset file byte count mismatch: {item.get('path', '<unknown>')}")
281
+ asset_path = _resolve_inside(base, str((data.get("asset") or {}).get("path", "")))
282
+ if asset_path is None or not asset_path.is_file():
283
+ errors.append(f"asset.path is missing: {(data.get('asset') or {}).get('path', '<unknown>')}")
284
+ return errors
285
+
286
+
287
+ def classify(data: dict[str, Any], errors: list[str] | None = None) -> dict[str, Any]:
288
+ errors = errors or []
289
+ authority = data.get("authority", "unknown")
290
+ readiness = data.get("readiness", "blocked")
291
+ effective = "blocked" if errors or authority == "unknown" else "runtime_ready"
292
+ if not errors:
293
+ if authority == "ai_generated":
294
+ effective = "runtime_ready"
295
+ elif authority in {"ai_assisted", "ai_assisted_human_reviewed"}:
296
+ review = data.get("human_review") or {}
297
+ if data.get("full_gate", {}).get("status") == "pass" and review.get("decision") == "approved" and review.get("user_confirmed") is True:
298
+ effective = "production_eligible"
299
+ elif authority == "ai_assisted_human_reviewed" or readiness == "review_required":
300
+ effective = "review_required"
301
+ else:
302
+ effective = "runtime_ready"
303
+ elif authority == "artist_authored":
304
+ effective = "production_eligible" if data.get("full_gate", {}).get("status") == "pass" else "runtime_ready"
305
+ if readiness == "production_approved" and data.get("human_approval", {}).get("user_confirmed") is True:
306
+ effective = "production_approved"
307
+ return {
308
+ "authority": authority,
309
+ "declared_readiness": readiness,
310
+ "effective_readiness": effective,
311
+ "production_eligible": effective in {"production_eligible", "production_approved"},
312
+ "production_approved": effective == "production_approved",
313
+ "errors": errors,
314
+ }
315
+
316
+
317
+ def evaluate(path: Path, *, base: Path | None = None, mode: str = "contract", manifest: dict[str, Any] | None = None) -> dict[str, Any]:
318
+ try:
319
+ data = read_json(path)
320
+ except ValueError as exc:
321
+ return {"status": "fail", "provenance_path": str(path), "errors": [str(exc)]}
322
+ errors = validate_document(data)
323
+ if manifest is not None:
324
+ manifest_file = manifest.get("file")
325
+ asset_path = (data.get("asset") or {}).get("path")
326
+ if manifest_file and asset_path != manifest_file:
327
+ errors.append("asset.path must match scene manifest.file")
328
+ provenance_ref = manifest.get("asset_provenance")
329
+ if provenance_ref and provenance_ref != path.name and provenance_ref != path.as_posix():
330
+ errors.append("scene manifest asset_provenance does not match the loaded provenance file")
331
+ if mode in {"runtime", "production"}:
332
+ if base is None:
333
+ errors.append(f"{mode} check requires --root for asset file hash verification")
334
+ else:
335
+ errors.extend(validate_files(data, base))
336
+ runtime = data.get("runtime_evidence") or {}
337
+ if runtime.get("status") != "pass":
338
+ errors.append("runtime check requires runtime_evidence.status=pass")
339
+ if mode == "production":
340
+ summary = classify(data, errors)
341
+ if summary["effective_readiness"] != "production_eligible":
342
+ errors.append("production check requires effective_readiness=production_eligible; production_approved remains a human-only state")
343
+ summary = classify(data, errors)
344
+ return {
345
+ "status": "pass" if not errors else "fail",
346
+ "mode": mode,
347
+ "provenance_path": str(path),
348
+ "summary": summary,
349
+ "errors": errors,
350
+ }
351
+
352
+
353
+ def print_result(result: dict[str, Any], as_json: bool = True) -> None:
354
+ if as_json:
355
+ print(json.dumps(result, indent=2, ensure_ascii=False))
356
+ return
357
+ print(f"Asset provenance: {result.get('status', 'fail').upper()}")
358
+ summary = result.get("summary") or {}
359
+ print(f"Authority: {summary.get('authority', 'unknown')}")
360
+ print(f"Declared readiness: {summary.get('declared_readiness', 'blocked')}")
361
+ print(f"Effective readiness: {summary.get('effective_readiness', 'blocked')}")
362
+ for error in result.get("errors", []):
363
+ print(f"- {error}")
364
+
365
+
366
+ def main() -> int:
367
+ parser = argparse.ArgumentParser(description="Validate, classify and report MotionLoom asset provenance")
368
+ sub = parser.add_subparsers(dest="command", required=True)
369
+ for name in ("validate", "classify", "check", "report"):
370
+ command = sub.add_parser(name)
371
+ command.add_argument("--input", required=True, help="Asset provenance JSON path")
372
+ command.add_argument("--root", help="Directory used to resolve and hash asset files")
373
+ command.add_argument("--mode", choices=("contract", "runtime", "production"), default="contract")
374
+ command.add_argument("--manifest", help="Optional scene manifest used for binding checks")
375
+ command.add_argument("--json", action="store_true", help="Emit JSON output")
376
+ args = parser.parse_args()
377
+ path = Path(args.input).expanduser().resolve()
378
+ base = Path(args.root).expanduser().resolve() if args.root else None
379
+ manifest = read_json(Path(args.manifest).expanduser().resolve()) if args.manifest else None
380
+ result = evaluate(path, base=base, mode=args.mode, manifest=manifest)
381
+ if args.command == "classify" and result.get("summary"):
382
+ result = {"status": result.get("status"), "provenance_path": str(path), "summary": result["summary"], "errors": result.get("errors", [])}
383
+ if args.command == "validate":
384
+ result["mode"] = "contract"
385
+ print_result(result, as_json=args.json or args.command != "report")
386
+ return 0 if result.get("status") == "pass" else 1
387
+
388
+
389
+ if __name__ == "__main__":
390
+ raise SystemExit(main())
package/scripts/devlab.py CHANGED
@@ -19,7 +19,7 @@ TASK_ARTIFACTS = (
19
19
  "issue-register.json", "decision-log.jsonl", "project-memory.json",
20
20
  "project-graph.json", "provenance.json", "capability-registry.json",
21
21
  "motion-ir.json", "replay-bundle.json", "semantic-lint-report.json",
22
- "continuity-report.json", "fix-plan.json", "browser-observation.md",
22
+ "continuity-report.json", "fix-plan.json", "visual-truth.json", "browser-observation.md",
23
23
  )
24
24
 
25
25
 
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env python3
2
+ """Validate and expose MotionLoom's cross-agent discovery contract.
3
+
4
+ The command is deliberately offline and read-only. It verifies that every
5
+ Agent-facing surface points back to the canonical root SKILL.md, that install
6
+ recipes name a deterministic verification command, and that the package can be
7
+ discovered from a clean npm/Git/local checkout without inferring approval.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import platform
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ SCHEMA_VERSION = "1.0"
22
+ EXIT_OK = 0
23
+ EXIT_USAGE = 2
24
+ EXIT_INVALID = 11
25
+
26
+
27
+ def repo_root() -> Path:
28
+ return Path(__file__).resolve().parent.parent
29
+
30
+
31
+ def load_json(path: Path) -> Any:
32
+ return json.loads(path.read_text(encoding="utf-8"))
33
+
34
+
35
+ def relpath(path: Path, root: Path) -> str:
36
+ return path.relative_to(root).as_posix()
37
+
38
+
39
+ def read_package(root: Path) -> dict[str, Any]:
40
+ package = load_json(root / "package.json")
41
+ return package if isinstance(package, dict) else {}
42
+
43
+
44
+ def git_remote(root: Path) -> str | None:
45
+ try:
46
+ result = subprocess.run(
47
+ ["git", "-C", str(root), "config", "--get", "remote.origin.url"],
48
+ capture_output=True,
49
+ text=True,
50
+ check=False,
51
+ )
52
+ except OSError:
53
+ return None
54
+ value = result.stdout.strip()
55
+ return value or None
56
+
57
+
58
+ def source_identity(root: Path) -> dict[str, Any]:
59
+ package = read_package(root)
60
+ return {
61
+ "name": package.get("name"),
62
+ "version": package.get("version"),
63
+ "root": str(root.resolve()),
64
+ "git_remote": git_remote(root),
65
+ "platform": platform.system().lower(),
66
+ "node_entrypoint": str((root / "bin" / "motionloom.mjs").resolve()),
67
+ "canonical_skill": str((root / "SKILL.md").resolve()),
68
+ }
69
+
70
+
71
+ def validate(root: Path) -> dict[str, Any]:
72
+ errors: list[str] = []
73
+ warnings: list[str] = []
74
+ root = root.resolve()
75
+ manifest_path = root / "agent-surfaces.json"
76
+
77
+ if not manifest_path.is_file():
78
+ return {"status": "fail", "errors": ["missing agent-surfaces.json"], "warnings": [], "root": str(root)}
79
+
80
+ try:
81
+ manifest = load_json(manifest_path)
82
+ except (OSError, json.JSONDecodeError) as exc:
83
+ return {"status": "fail", "errors": [f"invalid agent-surfaces.json: {exc}"], "warnings": [], "root": str(root)}
84
+
85
+ if manifest.get("schema_version") != SCHEMA_VERSION:
86
+ errors.append(f"unsupported schema_version: {manifest.get('schema_version')!r}")
87
+ package = read_package(root)
88
+ if manifest.get("name") != package.get("name"):
89
+ errors.append("manifest name does not match package.json")
90
+ if manifest.get("version") != package.get("version"):
91
+ errors.append("manifest version does not match package.json")
92
+ if manifest.get("canonical") != {
93
+ "skill": "SKILL.md",
94
+ "agent_card": "agent-card.json",
95
+ "cli": "bin/motionloom.mjs",
96
+ }:
97
+ errors.append("canonical paths do not match the package contract")
98
+
99
+ for required in ("SKILL.md", "agent-card.json", "bin/motionloom.mjs", "package.json"):
100
+ path = root / required
101
+ if not path.is_file():
102
+ errors.append(f"missing canonical file: {required}")
103
+
104
+ surfaces = manifest.get("surfaces")
105
+ if not isinstance(surfaces, list) or not surfaces:
106
+ errors.append("surfaces must be a non-empty array")
107
+ surfaces = []
108
+ ids: set[str] = set()
109
+ paths: set[str] = set()
110
+ for surface in surfaces:
111
+ if not isinstance(surface, dict):
112
+ errors.append("surface entry must be an object")
113
+ continue
114
+ surface_id = surface.get("id")
115
+ surface_path = surface.get("path")
116
+ if surface_id in ids:
117
+ errors.append(f"duplicate surface id: {surface_id}")
118
+ if isinstance(surface_id, str):
119
+ ids.add(surface_id)
120
+ if not isinstance(surface_path, str) or surface_path.startswith("/") or ".." in Path(surface_path).parts:
121
+ errors.append(f"surface path is not safe: {surface_path!r}")
122
+ continue
123
+ if surface_path in paths:
124
+ errors.append(f"duplicate surface path: {surface_path}")
125
+ paths.add(surface_path)
126
+ file_path = root / surface_path
127
+ if not file_path.is_file():
128
+ errors.append(f"missing surface file: {surface_path}")
129
+ if file_path.is_symlink():
130
+ errors.append(f"symlinked surface is not portable: {surface_path}")
131
+ if surface.get("canonical") != "SKILL.md":
132
+ errors.append(f"surface {surface_id!r} does not point to SKILL.md")
133
+ if surface.get("load_mode") not in {"alias", "router"}:
134
+ errors.append(f"surface {surface_id!r} has invalid load_mode")
135
+ if not isinstance(surface.get("agents"), list) or not surface.get("agents"):
136
+ errors.append(f"surface {surface_id!r} has no supported agents")
137
+
138
+ installations = manifest.get("installations")
139
+ if not isinstance(installations, list) or not installations:
140
+ errors.append("installations must be a non-empty array")
141
+ installations = []
142
+ installation_ids: set[str] = set()
143
+ for item in installations:
144
+ if not isinstance(item, dict):
145
+ errors.append("installation entry must be an object")
146
+ continue
147
+ item_id = item.get("id")
148
+ if item_id in installation_ids:
149
+ errors.append(f"duplicate installation id: {item_id}")
150
+ if isinstance(item_id, str):
151
+ installation_ids.add(item_id)
152
+ for key in ("source_kind", "command", "verification", "provenance"):
153
+ if not item.get(key):
154
+ errors.append(f"installation {item_id!r} missing {key}")
155
+
156
+ compatibility = manifest.get("compatibility", {})
157
+ for key in ("operating_systems", "node", "python", "agents"):
158
+ if not compatibility.get(key):
159
+ errors.append(f"compatibility missing {key}")
160
+ rules = manifest.get("rules", {})
161
+ if rules.get("canonical_instruction_source") != "SKILL.md":
162
+ errors.append("canonical_instruction_source must be SKILL.md")
163
+ for key in ("no_surface_copy", "no_network_required_for_check", "approval_is_never_inferred"):
164
+ if rules.get(key) is not True:
165
+ errors.append(f"rule {key} must remain true")
166
+
167
+ package_files = package.get("files", [])
168
+ for required_package_path in ("agent-surfaces.json", ".agents", ".claude", ".codex", "AGENTS.md"):
169
+ if required_package_path not in package_files:
170
+ warnings.append(f"package.json files does not explicitly include {required_package_path}")
171
+
172
+ return {
173
+ "status": "pass" if not errors else "fail",
174
+ "schema_version": SCHEMA_VERSION,
175
+ "root": str(root),
176
+ "source": source_identity(root),
177
+ "surface_count": len(surfaces),
178
+ "installation_count": len(installations),
179
+ "errors": errors,
180
+ "warnings": warnings,
181
+ }
182
+
183
+
184
+ def install_matrix(root: Path) -> dict[str, Any]:
185
+ result = validate(root)
186
+ manifest = load_json(root / "agent-surfaces.json") if (root / "agent-surfaces.json").is_file() else {}
187
+ rows = []
188
+ for item in manifest.get("installations", []):
189
+ rows.append({
190
+ "id": item.get("id"),
191
+ "source_kind": item.get("source_kind"),
192
+ "command": item.get("command"),
193
+ "verification": item.get("verification"),
194
+ "provenance": item.get("provenance"),
195
+ "status": "available" if result.get("status") == "pass" else "blocked_by_contract",
196
+ })
197
+ return {"status": result.get("status"), "matrix": rows, "compatibility": manifest.get("compatibility", {}), "errors": result.get("errors", [])}
198
+
199
+
200
+ def parser() -> argparse.ArgumentParser:
201
+ root_default = str(repo_root())
202
+ command = argparse.ArgumentParser(prog="motionloom discovery", description=__doc__)
203
+ sub = command.add_subparsers(dest="action", required=True)
204
+ for name, help_text in (
205
+ ("check", "Validate Agent surfaces and installation contract"),
206
+ ("show", "Print the canonical discovery manifest"),
207
+ ("source", "Print source identity for this checkout"),
208
+ ("install-matrix", "Print supported installation sources and verification commands"),
209
+ ):
210
+ child = sub.add_parser(name, help=help_text)
211
+ child.add_argument("--root", default=root_default, help="MotionLoom checkout root")
212
+ child.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
213
+ return command
214
+
215
+
216
+ def main(argv: list[str] | None = None) -> int:
217
+ args = parser().parse_args(argv)
218
+ root = Path(args.root).expanduser().resolve()
219
+ if args.action == "check":
220
+ result = validate(root)
221
+ elif args.action == "show":
222
+ try:
223
+ result = load_json(root / "agent-surfaces.json")
224
+ except (OSError, json.JSONDecodeError) as exc:
225
+ result = {"status": "fail", "errors": [str(exc)]}
226
+ elif args.action == "source":
227
+ result = source_identity(root)
228
+ else:
229
+ result = install_matrix(root)
230
+
231
+ if args.json:
232
+ print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
233
+ else:
234
+ if args.action == "show":
235
+ print(json.dumps(result, ensure_ascii=False, indent=2))
236
+ elif args.action == "source":
237
+ print(f"{result.get('name')}@{result.get('version')} — {result.get('platform')} — {result.get('root')}")
238
+ if result.get("git_remote"):
239
+ print(f"remote: {result['git_remote']}")
240
+ elif args.action == "install-matrix":
241
+ print(f"installation matrix: {result.get('status')}")
242
+ for row in result.get("matrix", []):
243
+ print(f"- {row['id']}: {row['command']} -> {row['verification']}")
244
+ else:
245
+ print(f"discovery contract: {result.get('status')}")
246
+ for error in result.get("errors", []):
247
+ print(f"error: {error}")
248
+ for warning in result.get("warnings", []):
249
+ print(f"warning: {warning}")
250
+ return EXIT_OK if result.get("status") in {None, "pass"} else EXIT_INVALID
251
+
252
+
253
+ if __name__ == "__main__":
254
+ try:
255
+ raise SystemExit(main())
256
+ except KeyboardInterrupt:
257
+ raise SystemExit(EXIT_USAGE)