@mc-and-his-agents/loom-installer 0.1.23 → 0.1.24

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 (22) hide show
  1. package/package.json +1 -1
  2. package/payload/manifest.json +42 -42
  3. package/payload/plugin/loom/skills/shared/scripts/loom_check.py +133 -9
  4. package/payload/plugin/loom/skills/shared/scripts/loom_flow.py +127 -15
  5. package/payload/skills/loom-adopt/.loom-runtime/shared/scripts/loom_check.py +133 -9
  6. package/payload/skills/loom-adopt/.loom-runtime/shared/scripts/loom_flow.py +127 -15
  7. package/payload/skills/loom-handoff/.loom-runtime/shared/scripts/loom_check.py +133 -9
  8. package/payload/skills/loom-handoff/.loom-runtime/shared/scripts/loom_flow.py +127 -15
  9. package/payload/skills/loom-init/.loom-runtime/shared/scripts/loom_check.py +133 -9
  10. package/payload/skills/loom-init/.loom-runtime/shared/scripts/loom_flow.py +127 -15
  11. package/payload/skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_check.py +133 -9
  12. package/payload/skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_flow.py +127 -15
  13. package/payload/skills/loom-pre-review/.loom-runtime/shared/scripts/loom_check.py +133 -9
  14. package/payload/skills/loom-pre-review/.loom-runtime/shared/scripts/loom_flow.py +127 -15
  15. package/payload/skills/loom-resume/.loom-runtime/shared/scripts/loom_check.py +133 -9
  16. package/payload/skills/loom-resume/.loom-runtime/shared/scripts/loom_flow.py +127 -15
  17. package/payload/skills/loom-retire/.loom-runtime/shared/scripts/loom_check.py +133 -9
  18. package/payload/skills/loom-retire/.loom-runtime/shared/scripts/loom_flow.py +127 -15
  19. package/payload/skills/loom-review/.loom-runtime/shared/scripts/loom_check.py +133 -9
  20. package/payload/skills/loom-review/.loom-runtime/shared/scripts/loom_flow.py +127 -15
  21. package/payload/skills/loom-spec-review/.loom-runtime/shared/scripts/loom_check.py +133 -9
  22. package/payload/skills/loom-spec-review/.loom-runtime/shared/scripts/loom_flow.py +127 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mc-and-his-agents/loom-installer",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "description": "Node installer for Loom plugin and single-skill installation surfaces.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,9 +2,9 @@
2
2
  "schema_version": "loom-installer-payload/v1",
3
3
  "loom_version": "0.4.0",
4
4
  "source_repository": "https://github.com/MC-and-his-Agents/Loom",
5
- "source_commit": "9ba44dcc76daf7e100edafd5943b99c4a69ba710",
5
+ "source_commit": "9ade7b1664d19d46002d8122e8c3eabba7cf6bf4",
6
6
  "source_ref": "main",
7
- "built_at": "2026-04-26T23:19:03+08:00",
7
+ "built_at": "2026-04-26T23:38:43+08:00",
8
8
  "runtime": {
9
9
  "python_minimum": "3.10",
10
10
  "python_recommended": "3.11+"
@@ -628,13 +628,13 @@
628
628
  },
629
629
  {
630
630
  "path": "plugin/loom/skills/shared/scripts/loom_check.py",
631
- "bytes": 278763,
632
- "sha256": "3b2257fddc37fdb514953500a38e94ca241a5889a5c6343b975db5a1109bbff4"
631
+ "bytes": 285470,
632
+ "sha256": "e834612a8da8cd22cc13679b61529bc0074d0399004f4e4f45540d6b8f314c9a"
633
633
  },
634
634
  {
635
635
  "path": "plugin/loom/skills/shared/scripts/loom_flow.py",
636
- "bytes": 290661,
637
- "sha256": "18c5dc39a4cd2982a40c729807b12c2b15efd41a2962719b5491b06dad83c028"
636
+ "bytes": 296133,
637
+ "sha256": "e8bc02d131b1007889b7f9b394b5b2d605c8bf790ba1baccc37da1e6aecbf46c"
638
638
  },
639
639
  {
640
640
  "path": "plugin/loom/skills/shared/scripts/loom_init.py",
@@ -1213,13 +1213,13 @@
1213
1213
  },
1214
1214
  {
1215
1215
  "path": "skills/loom-adopt/.loom-runtime/shared/scripts/loom_check.py",
1216
- "bytes": 278763,
1217
- "sha256": "3b2257fddc37fdb514953500a38e94ca241a5889a5c6343b975db5a1109bbff4"
1216
+ "bytes": 285470,
1217
+ "sha256": "e834612a8da8cd22cc13679b61529bc0074d0399004f4e4f45540d6b8f314c9a"
1218
1218
  },
1219
1219
  {
1220
1220
  "path": "skills/loom-adopt/.loom-runtime/shared/scripts/loom_flow.py",
1221
- "bytes": 290661,
1222
- "sha256": "18c5dc39a4cd2982a40c729807b12c2b15efd41a2962719b5491b06dad83c028"
1221
+ "bytes": 296133,
1222
+ "sha256": "e8bc02d131b1007889b7f9b394b5b2d605c8bf790ba1baccc37da1e6aecbf46c"
1223
1223
  },
1224
1224
  {
1225
1225
  "path": "skills/loom-adopt/.loom-runtime/shared/scripts/loom_init.py",
@@ -1828,13 +1828,13 @@
1828
1828
  },
1829
1829
  {
1830
1830
  "path": "skills/loom-handoff/.loom-runtime/shared/scripts/loom_check.py",
1831
- "bytes": 278763,
1832
- "sha256": "3b2257fddc37fdb514953500a38e94ca241a5889a5c6343b975db5a1109bbff4"
1831
+ "bytes": 285470,
1832
+ "sha256": "e834612a8da8cd22cc13679b61529bc0074d0399004f4e4f45540d6b8f314c9a"
1833
1833
  },
1834
1834
  {
1835
1835
  "path": "skills/loom-handoff/.loom-runtime/shared/scripts/loom_flow.py",
1836
- "bytes": 290661,
1837
- "sha256": "18c5dc39a4cd2982a40c729807b12c2b15efd41a2962719b5491b06dad83c028"
1836
+ "bytes": 296133,
1837
+ "sha256": "e8bc02d131b1007889b7f9b394b5b2d605c8bf790ba1baccc37da1e6aecbf46c"
1838
1838
  },
1839
1839
  {
1840
1840
  "path": "skills/loom-handoff/.loom-runtime/shared/scripts/loom_init.py",
@@ -2443,13 +2443,13 @@
2443
2443
  },
2444
2444
  {
2445
2445
  "path": "skills/loom-init/.loom-runtime/shared/scripts/loom_check.py",
2446
- "bytes": 278763,
2447
- "sha256": "3b2257fddc37fdb514953500a38e94ca241a5889a5c6343b975db5a1109bbff4"
2446
+ "bytes": 285470,
2447
+ "sha256": "e834612a8da8cd22cc13679b61529bc0074d0399004f4e4f45540d6b8f314c9a"
2448
2448
  },
2449
2449
  {
2450
2450
  "path": "skills/loom-init/.loom-runtime/shared/scripts/loom_flow.py",
2451
- "bytes": 290661,
2452
- "sha256": "18c5dc39a4cd2982a40c729807b12c2b15efd41a2962719b5491b06dad83c028"
2451
+ "bytes": 296133,
2452
+ "sha256": "e8bc02d131b1007889b7f9b394b5b2d605c8bf790ba1baccc37da1e6aecbf46c"
2453
2453
  },
2454
2454
  {
2455
2455
  "path": "skills/loom-init/.loom-runtime/shared/scripts/loom_init.py",
@@ -3063,13 +3063,13 @@
3063
3063
  },
3064
3064
  {
3065
3065
  "path": "skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_check.py",
3066
- "bytes": 278763,
3067
- "sha256": "3b2257fddc37fdb514953500a38e94ca241a5889a5c6343b975db5a1109bbff4"
3066
+ "bytes": 285470,
3067
+ "sha256": "e834612a8da8cd22cc13679b61529bc0074d0399004f4e4f45540d6b8f314c9a"
3068
3068
  },
3069
3069
  {
3070
3070
  "path": "skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_flow.py",
3071
- "bytes": 290661,
3072
- "sha256": "18c5dc39a4cd2982a40c729807b12c2b15efd41a2962719b5491b06dad83c028"
3071
+ "bytes": 296133,
3072
+ "sha256": "e8bc02d131b1007889b7f9b394b5b2d605c8bf790ba1baccc37da1e6aecbf46c"
3073
3073
  },
3074
3074
  {
3075
3075
  "path": "skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_init.py",
@@ -3678,13 +3678,13 @@
3678
3678
  },
3679
3679
  {
3680
3680
  "path": "skills/loom-pre-review/.loom-runtime/shared/scripts/loom_check.py",
3681
- "bytes": 278763,
3682
- "sha256": "3b2257fddc37fdb514953500a38e94ca241a5889a5c6343b975db5a1109bbff4"
3681
+ "bytes": 285470,
3682
+ "sha256": "e834612a8da8cd22cc13679b61529bc0074d0399004f4e4f45540d6b8f314c9a"
3683
3683
  },
3684
3684
  {
3685
3685
  "path": "skills/loom-pre-review/.loom-runtime/shared/scripts/loom_flow.py",
3686
- "bytes": 290661,
3687
- "sha256": "18c5dc39a4cd2982a40c729807b12c2b15efd41a2962719b5491b06dad83c028"
3686
+ "bytes": 296133,
3687
+ "sha256": "e8bc02d131b1007889b7f9b394b5b2d605c8bf790ba1baccc37da1e6aecbf46c"
3688
3688
  },
3689
3689
  {
3690
3690
  "path": "skills/loom-pre-review/.loom-runtime/shared/scripts/loom_init.py",
@@ -4293,13 +4293,13 @@
4293
4293
  },
4294
4294
  {
4295
4295
  "path": "skills/loom-resume/.loom-runtime/shared/scripts/loom_check.py",
4296
- "bytes": 278763,
4297
- "sha256": "3b2257fddc37fdb514953500a38e94ca241a5889a5c6343b975db5a1109bbff4"
4296
+ "bytes": 285470,
4297
+ "sha256": "e834612a8da8cd22cc13679b61529bc0074d0399004f4e4f45540d6b8f314c9a"
4298
4298
  },
4299
4299
  {
4300
4300
  "path": "skills/loom-resume/.loom-runtime/shared/scripts/loom_flow.py",
4301
- "bytes": 290661,
4302
- "sha256": "18c5dc39a4cd2982a40c729807b12c2b15efd41a2962719b5491b06dad83c028"
4301
+ "bytes": 296133,
4302
+ "sha256": "e8bc02d131b1007889b7f9b394b5b2d605c8bf790ba1baccc37da1e6aecbf46c"
4303
4303
  },
4304
4304
  {
4305
4305
  "path": "skills/loom-resume/.loom-runtime/shared/scripts/loom_init.py",
@@ -4908,13 +4908,13 @@
4908
4908
  },
4909
4909
  {
4910
4910
  "path": "skills/loom-retire/.loom-runtime/shared/scripts/loom_check.py",
4911
- "bytes": 278763,
4912
- "sha256": "3b2257fddc37fdb514953500a38e94ca241a5889a5c6343b975db5a1109bbff4"
4911
+ "bytes": 285470,
4912
+ "sha256": "e834612a8da8cd22cc13679b61529bc0074d0399004f4e4f45540d6b8f314c9a"
4913
4913
  },
4914
4914
  {
4915
4915
  "path": "skills/loom-retire/.loom-runtime/shared/scripts/loom_flow.py",
4916
- "bytes": 290661,
4917
- "sha256": "18c5dc39a4cd2982a40c729807b12c2b15efd41a2962719b5491b06dad83c028"
4916
+ "bytes": 296133,
4917
+ "sha256": "e8bc02d131b1007889b7f9b394b5b2d605c8bf790ba1baccc37da1e6aecbf46c"
4918
4918
  },
4919
4919
  {
4920
4920
  "path": "skills/loom-retire/.loom-runtime/shared/scripts/loom_init.py",
@@ -5523,13 +5523,13 @@
5523
5523
  },
5524
5524
  {
5525
5525
  "path": "skills/loom-review/.loom-runtime/shared/scripts/loom_check.py",
5526
- "bytes": 278763,
5527
- "sha256": "3b2257fddc37fdb514953500a38e94ca241a5889a5c6343b975db5a1109bbff4"
5526
+ "bytes": 285470,
5527
+ "sha256": "e834612a8da8cd22cc13679b61529bc0074d0399004f4e4f45540d6b8f314c9a"
5528
5528
  },
5529
5529
  {
5530
5530
  "path": "skills/loom-review/.loom-runtime/shared/scripts/loom_flow.py",
5531
- "bytes": 290661,
5532
- "sha256": "18c5dc39a4cd2982a40c729807b12c2b15efd41a2962719b5491b06dad83c028"
5531
+ "bytes": 296133,
5532
+ "sha256": "e8bc02d131b1007889b7f9b394b5b2d605c8bf790ba1baccc37da1e6aecbf46c"
5533
5533
  },
5534
5534
  {
5535
5535
  "path": "skills/loom-review/.loom-runtime/shared/scripts/loom_init.py",
@@ -6138,13 +6138,13 @@
6138
6138
  },
6139
6139
  {
6140
6140
  "path": "skills/loom-spec-review/.loom-runtime/shared/scripts/loom_check.py",
6141
- "bytes": 278763,
6142
- "sha256": "3b2257fddc37fdb514953500a38e94ca241a5889a5c6343b975db5a1109bbff4"
6141
+ "bytes": 285470,
6142
+ "sha256": "e834612a8da8cd22cc13679b61529bc0074d0399004f4e4f45540d6b8f314c9a"
6143
6143
  },
6144
6144
  {
6145
6145
  "path": "skills/loom-spec-review/.loom-runtime/shared/scripts/loom_flow.py",
6146
- "bytes": 290661,
6147
- "sha256": "18c5dc39a4cd2982a40c729807b12c2b15efd41a2962719b5491b06dad83c028"
6146
+ "bytes": 296133,
6147
+ "sha256": "e8bc02d131b1007889b7f9b394b5b2d605c8bf790ba1baccc37da1e6aecbf46c"
6148
6148
  },
6149
6149
  {
6150
6150
  "path": "skills/loom-spec-review/.loom-runtime/shared/scripts/loom_init.py",
@@ -4,6 +4,7 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import re
7
+ import hashlib
7
8
  import shutil
8
9
  import subprocess
9
10
  import sys
@@ -1071,6 +1072,14 @@ def require_shadow_parity_payload(
1071
1072
  locator = surface_payload.get("locator")
1072
1073
  if not isinstance(locator, str) or not locator:
1073
1074
  failures.append(Failure(category, f"{context} reports[{index}] `{surface_key}.locator` must be non-empty"))
1075
+ if surface_payload.get("status") == "readable":
1076
+ source_files = surface_payload.get("source_files")
1077
+ source_sha256 = surface_payload.get("source_sha256")
1078
+ if not isinstance(source_files, list) or not source_files:
1079
+ failures.append(Failure(category, f"{context} reports[{index}] `{surface_key}.source_files` must be non-empty for readable evidence"))
1080
+ source_files = []
1081
+ if not isinstance(source_sha256, dict) or set(source_sha256) != set(source_files):
1082
+ failures.append(Failure(category, f"{context} reports[{index}] `{surface_key}.source_sha256` must match source_files exactly"))
1074
1083
 
1075
1084
 
1076
1085
  def require_host_lifecycle_payload(
@@ -5472,6 +5481,29 @@ def check_repo_interop_contracts(root: Path) -> list[Failure]:
5472
5481
  path.parent.mkdir(parents=True, exist_ok=True)
5473
5482
  path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
5474
5483
 
5484
+ def sha256_file(path: Path) -> str:
5485
+ digest = hashlib.sha256()
5486
+ with path.open("rb") as handle:
5487
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
5488
+ digest.update(chunk)
5489
+ return digest.hexdigest()
5490
+
5491
+ def write_shadow_evidence(target: Path, evidence: str, value_key: str, value: str, source: str) -> None:
5492
+ source_path = target / source
5493
+ source_path.parent.mkdir(parents=True, exist_ok=True)
5494
+ if not source_path.exists():
5495
+ write_json(source_path, {"value": value})
5496
+ write_json(
5497
+ target / evidence,
5498
+ {
5499
+ value_key: value,
5500
+ "source_files": [source],
5501
+ "source_sha256": {
5502
+ source: sha256_file(source_path),
5503
+ },
5504
+ },
5505
+ )
5506
+
5475
5507
  def install_interop(
5476
5508
  target: Path,
5477
5509
  *,
@@ -5484,17 +5516,21 @@ def check_repo_interop_contracts(root: Path) -> list[Failure]:
5484
5516
  (target / ".loom" / "shadow").mkdir(parents=True, exist_ok=True)
5485
5517
  (target / "native" / "status").mkdir(parents=True, exist_ok=True)
5486
5518
  for relative, payload in {
5487
- ".loom/shadow/admission-loom.json": {"result": "pass"},
5488
- ".loom/shadow/admission-repo.json": {"result": "pass"},
5489
- ".loom/shadow/review-loom.json": {"decision": "allow"},
5490
- ".loom/shadow/review-repo.json": {"decision": "allow"},
5491
- ".loom/shadow/merge-ready-loom.json": {"status": "pass"},
5492
- ".loom/shadow/merge-ready-repo.json": {"status": "pass"},
5493
- ".loom/shadow/closeout-loom.json": {"status": "done"},
5494
- ".loom/shadow/closeout-repo.json": {"status": "done"},
5495
5519
  "host/guardian-review.json": {"verdict": "allow"},
5520
+ "native/status/admission.json": {"result": "pass"},
5521
+ "native/status/review.json": {"decision": "allow"},
5522
+ "native/status/merge-ready.json": {"status": "pass"},
5523
+ "native/status/closeout.json": {"status": "done"},
5496
5524
  }.items():
5497
5525
  write_json(target / relative, payload)
5526
+ write_shadow_evidence(target, ".loom/shadow/admission-loom.json", "result", "pass", ".loom/status/current.md")
5527
+ write_shadow_evidence(target, ".loom/shadow/admission-repo.json", "result", "pass", "native/status/admission.json")
5528
+ write_shadow_evidence(target, ".loom/shadow/review-loom.json", "decision", "allow", "host/guardian-review.json")
5529
+ write_shadow_evidence(target, ".loom/shadow/review-repo.json", "decision", "allow", "native/status/review.json")
5530
+ write_shadow_evidence(target, ".loom/shadow/merge-ready-loom.json", "status", "pass", "host/guardian-review.json")
5531
+ write_shadow_evidence(target, ".loom/shadow/merge-ready-repo.json", "status", "pass", "native/status/merge-ready.json")
5532
+ write_shadow_evidence(target, ".loom/shadow/closeout-loom.json", "status", "done", ".loom/status/current.md")
5533
+ write_shadow_evidence(target, ".loom/shadow/closeout-repo.json", "status", "done", "native/status/closeout.json")
5498
5534
  if interop is not None:
5499
5535
  write_json(companion_dir / "interop.json", interop)
5500
5536
 
@@ -5641,7 +5677,8 @@ def check_repo_interop_contracts(root: Path) -> list[Failure]:
5641
5677
 
5642
5678
  mismatch_target = base / "mismatch"
5643
5679
  shutil.copytree(present_target, mismatch_target)
5644
- write_json(mismatch_target / ".loom/shadow/review-repo.json", {"decision": "block"})
5680
+ write_json(mismatch_target / "native/status/review.json", {"decision": "block"})
5681
+ write_shadow_evidence(mismatch_target, ".loom/shadow/review-repo.json", "decision", "block", "native/status/review.json")
5645
5682
  mismatch_payload, error = load_command_json(
5646
5683
  root,
5647
5684
  ["python3", "tools/loom_flow.py", "shadow-parity", "--target", str(mismatch_target), "--surface", "review"],
@@ -5716,6 +5753,93 @@ def check_repo_interop_contracts(root: Path) -> list[Failure]:
5716
5753
  if blocking_unreadable_payload.get("result") != "block":
5717
5754
  failures.append(Failure("repo-interop", "`shadow-parity --blocking` must block unreadable surfaces"))
5718
5755
 
5756
+ missing_hash_target = base / "missing-hash"
5757
+ shutil.copytree(present_target, missing_hash_target)
5758
+ write_json(
5759
+ missing_hash_target / ".loom/shadow/review-repo.json",
5760
+ {
5761
+ "decision": "allow",
5762
+ "source_files": ["native/status/review.json"],
5763
+ },
5764
+ )
5765
+ missing_hash_payload, error = load_command_json(
5766
+ root,
5767
+ ["python3", "tools/loom_flow.py", "shadow-parity", "--target", str(missing_hash_target), "--surface", "review"],
5768
+ )
5769
+ if error:
5770
+ failures.append(Failure("repo-interop", f"`shadow-parity` missing hash sample failed: {error}"))
5771
+ else:
5772
+ require_shadow_parity_payload(
5773
+ failures,
5774
+ category="repo-interop",
5775
+ context="`shadow-parity` missing hash sample",
5776
+ payload=missing_hash_payload,
5777
+ expected_reports=1,
5778
+ )
5779
+ reports = missing_hash_payload.get("reports")
5780
+ if not isinstance(reports, list) or not reports or reports[0].get("result") != "unreadable":
5781
+ failures.append(Failure("repo-interop", "`shadow-parity` missing hash sample must report `unreadable`"))
5782
+
5783
+ partial_hash_target = base / "partial-hash"
5784
+ shutil.copytree(present_target, partial_hash_target)
5785
+ write_json(
5786
+ partial_hash_target / ".loom/shadow/review-repo.json",
5787
+ {
5788
+ "decision": "allow",
5789
+ "source_files": ["native/status/review.json", "host/guardian-review.json"],
5790
+ "source_sha256": {
5791
+ "native/status/review.json": sha256_file(partial_hash_target / "native/status/review.json"),
5792
+ },
5793
+ },
5794
+ )
5795
+ partial_hash_payload, error = load_command_json(
5796
+ root,
5797
+ ["python3", "tools/loom_flow.py", "shadow-parity", "--target", str(partial_hash_target), "--surface", "review"],
5798
+ )
5799
+ if error:
5800
+ failures.append(Failure("repo-interop", f"`shadow-parity` partial hash sample failed: {error}"))
5801
+ else:
5802
+ reports = partial_hash_payload.get("reports")
5803
+ if not isinstance(reports, list) or not reports or reports[0].get("result") != "unreadable":
5804
+ failures.append(Failure("repo-interop", "`shadow-parity` partial hash sample must report `unreadable`"))
5805
+
5806
+ hash_drift_target = base / "hash-drift"
5807
+ shutil.copytree(present_target, hash_drift_target)
5808
+ write_json(hash_drift_target / "native/status/review.json", {"decision": "changed-after-evidence"})
5809
+ hash_drift_payload, error = load_command_json(
5810
+ root,
5811
+ ["python3", "tools/loom_flow.py", "shadow-parity", "--target", str(hash_drift_target), "--surface", "review"],
5812
+ )
5813
+ if error:
5814
+ failures.append(Failure("repo-interop", f"`shadow-parity` hash drift sample failed: {error}"))
5815
+ else:
5816
+ reports = hash_drift_payload.get("reports")
5817
+ if not isinstance(reports, list) or not reports or reports[0].get("result") != "unreadable":
5818
+ failures.append(Failure("repo-interop", "`shadow-parity` hash drift sample must report `unreadable`"))
5819
+
5820
+ rogue_target = base / "rogue"
5821
+ shutil.copytree(present_target, rogue_target)
5822
+ write_json(
5823
+ rogue_target / ".loom/shadow/rogue.json",
5824
+ {
5825
+ "result": "pass",
5826
+ "source_files": ["native/status/review.json"],
5827
+ "source_sha256": {
5828
+ "native/status/review.json": sha256_file(rogue_target / "native/status/review.json"),
5829
+ },
5830
+ },
5831
+ )
5832
+ rogue_payload, error = load_command_json(
5833
+ root,
5834
+ ["python3", "tools/loom_flow.py", "shadow-parity", "--target", str(rogue_target), "--surface", "review"],
5835
+ )
5836
+ if error:
5837
+ failures.append(Failure("repo-interop", f"`shadow-parity` rogue evidence sample failed: {error}"))
5838
+ else:
5839
+ reports = rogue_payload.get("reports")
5840
+ if not isinstance(reports, list) or not reports or reports[0].get("result") != "unreadable":
5841
+ failures.append(Failure("repo-interop", "`shadow-parity` rogue evidence sample must report `unreadable`"))
5842
+
5719
5843
  return failures
5720
5844
 
5721
5845
 
@@ -4,6 +4,7 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
+ import hashlib
7
8
  import json
8
9
  import os
9
10
  import re
@@ -531,15 +532,110 @@ def load_repo_interop_contract(repo_interop: object, *, target_root: Path) -> tu
531
532
  return payload, []
532
533
 
533
534
 
534
- def normalized_shadow_value(path: Path) -> tuple[str | None, str | None]:
535
+ def sha256_file(path: Path) -> str:
536
+ digest = hashlib.sha256()
537
+ with path.open("rb") as handle:
538
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
539
+ digest.update(chunk)
540
+ return digest.hexdigest()
541
+
542
+
543
+ def repo_relative_path(target_root: Path, relative: str) -> Path | None:
544
+ candidate = (target_root / relative).resolve()
545
+ try:
546
+ candidate.relative_to(target_root.resolve())
547
+ except ValueError:
548
+ return None
549
+ return candidate
550
+
551
+
552
+ def validate_shadow_sources(payload: dict[str, Any], *, path: Path, target_root: Path) -> tuple[dict[str, Any], list[str]]:
553
+ source_files = payload.get("source_files")
554
+ source_sha256 = payload.get("source_sha256")
555
+ errors: list[str] = []
556
+ if not isinstance(source_files, list) or not source_files:
557
+ errors.append(f"shadow evidence `{path}` must declare non-empty `source_files`")
558
+ source_files = []
559
+ if not isinstance(source_sha256, dict) or not source_sha256:
560
+ errors.append(f"shadow evidence `{path}` must declare non-empty `source_sha256`")
561
+ source_sha256 = {}
562
+
563
+ normalized_sources: list[str] = []
564
+ for index, source in enumerate(source_files, start=1):
565
+ if not isinstance(source, str) or not source.strip():
566
+ errors.append(f"shadow evidence `{path}` source_files[{index}] must be a non-empty relative path")
567
+ continue
568
+ source = source.strip()
569
+ if Path(source).is_absolute() or ".." in Path(source).parts:
570
+ errors.append(f"shadow evidence `{path}` source `{source}` must stay inside the repository")
571
+ continue
572
+ source_path = repo_relative_path(target_root, source)
573
+ if source_path is None:
574
+ errors.append(f"shadow evidence `{path}` source `{source}` must stay inside the repository")
575
+ continue
576
+ if not source_path.exists() or source_path.is_dir():
577
+ errors.append(f"shadow evidence `{path}` source `{source}` must be an existing file")
578
+ continue
579
+ normalized_sources.append(source)
580
+
581
+ source_keys = set(normalized_sources)
582
+ hash_keys = {key for key in source_sha256.keys() if isinstance(key, str)}
583
+ if source_keys != hash_keys:
584
+ errors.append(f"shadow evidence `{path}` source_files and source_sha256 keys must match exactly")
585
+ for source in sorted(source_keys & hash_keys):
586
+ expected = source_sha256.get(source)
587
+ if not isinstance(expected, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", expected):
588
+ errors.append(f"shadow evidence `{path}` source `{source}` must declare a 64-character sha256")
589
+ continue
590
+ actual = sha256_file(target_root / source)
591
+ if actual.lower() != expected.lower():
592
+ errors.append(f"shadow evidence `{path}` source `{source}` sha256 drifted")
593
+
594
+ return {
595
+ "source_files": normalized_sources,
596
+ "source_sha256": {source: source_sha256.get(source) for source in normalized_sources if isinstance(source_sha256.get(source), str)},
597
+ }, errors
598
+
599
+
600
+ def declared_shadow_locators(interop_payload: dict[str, Any]) -> set[str]:
601
+ shadow_surfaces = interop_payload.get("shadow_surfaces")
602
+ declared: set[str] = set()
603
+ if not isinstance(shadow_surfaces, dict):
604
+ return declared
605
+ for entry in shadow_surfaces.values():
606
+ if not isinstance(entry, dict):
607
+ continue
608
+ for key in ("loom_locator", "repo_locator"):
609
+ value = entry.get(key)
610
+ if isinstance(value, str) and value.strip():
611
+ declared.add(value.strip())
612
+ return declared
613
+
614
+
615
+ def undeclared_shadow_evidence_errors(target_root: Path, interop_payload: dict[str, Any]) -> list[str]:
616
+ shadow_root = target_root / ".loom/shadow"
617
+ if not shadow_root.exists():
618
+ return []
619
+ declared = declared_shadow_locators(interop_payload)
620
+ errors: list[str] = []
621
+ for path in sorted(shadow_root.glob("*.json")):
622
+ relative = path.relative_to(target_root).as_posix()
623
+ if relative == ".loom/shadow/shadow-parity.json":
624
+ continue
625
+ if relative not in declared:
626
+ errors.append(f"shadow evidence `{relative}` is not declared in repo interop shadow_surfaces")
627
+ return errors
628
+
629
+
630
+ def normalized_shadow_value(path: Path, *, target_root: Path) -> tuple[dict[str, Any], str | None]:
535
631
  try:
536
632
  if path.is_dir():
537
- return None, f"shadow parity locator points to a directory: {path}"
633
+ return {"normalized_value": None}, f"shadow parity locator points to a directory: {path}"
538
634
  raw_text = path.read_text(encoding="utf-8")
539
635
  except OSError as exc:
540
- return None, f"cannot read shadow parity locator `{path}`: {exc.strerror or exc}"
636
+ return {"normalized_value": None}, f"cannot read shadow parity locator `{path}`: {exc.strerror or exc}"
541
637
  if not raw_text.strip():
542
- return None, f"shadow parity locator is empty: {path}"
638
+ return {"normalized_value": None}, f"shadow parity locator is empty: {path}"
543
639
 
544
640
  try:
545
641
  payload = json.loads(raw_text)
@@ -547,21 +643,22 @@ def normalized_shadow_value(path: Path) -> tuple[str | None, str | None]:
547
643
  payload = None
548
644
 
549
645
  if isinstance(payload, dict):
646
+ source_evidence, source_errors = validate_shadow_sources(payload, path=path, target_root=target_root)
550
647
  for key in ("parity_value", "result", "decision", "status", "verdict", "value"):
551
648
  value = payload.get(key)
552
649
  if isinstance(value, (str, int, float, bool)) and str(value).strip():
553
- return str(value).strip().lower(), None
554
- return json.dumps(payload, ensure_ascii=False, sort_keys=True), None
650
+ return {**source_evidence, "normalized_value": str(value).strip().lower()}, "; ".join(source_errors) if source_errors else None
651
+ return {**source_evidence, "normalized_value": json.dumps(payload, ensure_ascii=False, sort_keys=True)}, "; ".join(source_errors) if source_errors else None
555
652
  if isinstance(payload, list):
556
- return json.dumps(payload, ensure_ascii=False, sort_keys=True), None
653
+ return {"normalized_value": json.dumps(payload, ensure_ascii=False, sort_keys=True)}, f"shadow evidence `{path}` must be a JSON object with source_files/source_sha256"
557
654
  if isinstance(payload, (str, int, float, bool)) and str(payload).strip():
558
- return str(payload).strip().lower(), None
655
+ return {"normalized_value": str(payload).strip().lower()}, f"shadow evidence `{path}` must be a JSON object with source_files/source_sha256"
559
656
 
560
657
  for line in raw_text.splitlines():
561
658
  stripped = line.strip()
562
659
  if stripped and not stripped.startswith("#"):
563
- return stripped.lower(), None
564
- return None, f"shadow parity locator does not expose a comparable value: {path}"
660
+ return {"normalized_value": stripped.lower()}, f"shadow evidence `{path}` must be a JSON object with source_files/source_sha256"
661
+ return {"normalized_value": None}, f"shadow parity locator does not expose a comparable value: {path}"
565
662
 
566
663
 
567
664
  def shadow_parity_report(
@@ -632,27 +729,42 @@ def shadow_parity_report(
632
729
  loom_path = target_root / str(loom_locator)
633
730
  repo_path = target_root / str(repo_locator)
634
731
 
635
- loom_value, loom_error = normalized_shadow_value(loom_path)
636
- repo_value, repo_error = normalized_shadow_value(repo_path)
732
+ loom_surface = {
733
+ "status": "missing",
734
+ "locator": str(loom_locator),
735
+ "normalized_value": None,
736
+ }
737
+ repo_surface = {
738
+ "status": "missing",
739
+ "locator": str(repo_locator),
740
+ "normalized_value": None,
741
+ }
742
+
743
+ global_errors = undeclared_shadow_evidence_errors(target_root, interop_payload)
744
+ loom_evidence, loom_error = normalized_shadow_value(loom_path, target_root=target_root)
745
+ repo_evidence, repo_error = normalized_shadow_value(repo_path, target_root=target_root)
746
+ loom_value = loom_evidence.get("normalized_value")
747
+ repo_value = repo_evidence.get("normalized_value")
637
748
 
638
749
  missing_inputs: list[str] = []
750
+ missing_inputs.extend(global_errors)
639
751
  if loom_error:
640
752
  missing_inputs.append(loom_error)
641
753
  if repo_error:
642
754
  missing_inputs.append(repo_error)
643
755
 
644
756
  loom_surface = {
757
+ **loom_evidence,
645
758
  "status": "readable" if loom_error is None else "missing",
646
759
  "locator": str(loom_locator),
647
- "normalized_value": loom_value,
648
760
  }
649
761
  repo_surface = {
762
+ **repo_evidence,
650
763
  "status": "readable" if repo_error is None else "missing",
651
764
  "locator": str(repo_locator),
652
- "normalized_value": repo_value,
653
765
  }
654
766
 
655
- if loom_error or repo_error or loom_value is None or repo_value is None:
767
+ if global_errors or loom_error or repo_error or loom_value is None or repo_value is None:
656
768
  return {
657
769
  **empty_report,
658
770
  "summary": "shadow parity is unreadable because one or both declared surfaces cannot be normalized.",