tirtc-device-builder 0.4.0 → 0.6.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 (26) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +30 -13
  4. package/package.json +1 -1
  5. package/skills/tirtc-esp32-builder/SKILL.md +11 -5
  6. package/skills/tirtc-esp32-builder/USAGE.md +7 -3
  7. package/skills/tirtc-esp32-builder/assets/board-audio-contract.example.json +66 -0
  8. package/skills/tirtc-esp32-builder/assets/board-video-contract.example.json +94 -0
  9. package/skills/tirtc-esp32-builder/assets/developer-intake-prompt.md +12 -6
  10. package/skills/tirtc-esp32-builder/assets/hardware-ir-v2.example.json +5 -0
  11. package/skills/tirtc-esp32-builder/assets/lckfb-szpi-esp32s3-portable-prompt.md +51 -0
  12. package/skills/tirtc-esp32-builder/assets/report-template.md +11 -0
  13. package/skills/tirtc-esp32-builder/references/audio-contract.md +51 -0
  14. package/skills/tirtc-esp32-builder/references/capability-rules.md +11 -7
  15. package/skills/tirtc-esp32-builder/references/environment.md +8 -0
  16. package/skills/tirtc-esp32-builder/references/hardware-ir.md +18 -1
  17. package/skills/tirtc-esp32-builder/references/porting-risks.md +1 -1
  18. package/skills/tirtc-esp32-builder/references/reporting.md +6 -2
  19. package/skills/tirtc-esp32-builder/references/video-contract.md +30 -0
  20. package/skills/tirtc-esp32-builder/references/workflow.md +10 -2
  21. package/skills/tirtc-esp32-builder/scripts/audio_contract.py +320 -0
  22. package/skills/tirtc-esp32-builder/scripts/hardware_ir.py +406 -68
  23. package/skills/tirtc-esp32-builder/scripts/install_audio_gate.py +66 -0
  24. package/skills/tirtc-esp32-builder/scripts/install_video_gate.py +66 -0
  25. package/skills/tirtc-esp32-builder/scripts/project_portability.py +86 -0
  26. package/skills/tirtc-esp32-builder/scripts/video_contract.py +333 -0
@@ -11,6 +11,9 @@ import sys
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
14
+ from audio_contract import verify_contract as verify_audio_contract
15
+ from video_contract import verify_contract as verify_video_contract
16
+
14
17
 
15
18
  FEATURES = {
16
19
  "h5_live_audio",
@@ -18,6 +21,8 @@ FEATURES = {
18
21
  "h5_talkback",
19
22
  "ai_talk",
20
23
  }
24
+ AUDIO_FEATURES = {"h5_live_audio", "h5_talkback", "ai_talk"}
25
+ VIDEO_FEATURES = {"h5_live_video"}
21
26
  VERIFICATION_LEVELS = {
22
27
  "extracted": 1,
23
28
  "corroborated": 2,
@@ -25,7 +30,12 @@ VERIFICATION_LEVELS = {
25
30
  "hardware_verified": 4,
26
31
  "hil_verified": 5,
27
32
  }
28
- READY_STATUSES = {"READY_TO_PORT", "HIL_VERIFIED"}
33
+ ASSESSMENT_PHASES = {"intake", "build", "hil"}
34
+ PHASE_SUCCESS_STATUSES = {
35
+ "intake": {"READY_TO_PORT", "HIL_VERIFIED"},
36
+ "build": {"BUILD_VERIFIED", "HIL_VERIFIED"},
37
+ "hil": {"HIL_VERIFIED"},
38
+ }
29
39
  VIDEO_CONTRACTS = {
30
40
  "mjpeg": "jpeg_complete_frames",
31
41
  "h264": "h264_annex_b_access_units",
@@ -215,6 +225,26 @@ def validate_hardware_resources(
215
225
  validate_verification(
216
226
  i2s.get("verification"), "hardware_resources.i2s.verification", errors
217
227
  )
228
+ audio_contract = resources.get("audio_semantic_contract")
229
+ nullable_string(
230
+ audio_contract,
231
+ "hardware_resources.audio_semantic_contract",
232
+ errors,
233
+ )
234
+ if isinstance(audio_contract, str) and Path(audio_contract).is_absolute():
235
+ errors.append(
236
+ "hardware_resources.audio_semantic_contract must be project-relative"
237
+ )
238
+ video_contract = resources.get("video_semantic_contract")
239
+ nullable_string(
240
+ video_contract,
241
+ "hardware_resources.video_semantic_contract",
242
+ errors,
243
+ )
244
+ if isinstance(video_contract, str) and Path(video_contract).is_absolute():
245
+ errors.append(
246
+ "hardware_resources.video_semantic_contract must be project-relative"
247
+ )
218
248
 
219
249
  mapping_section = mapping(
220
250
  resources.get("audio_channel_mapping"),
@@ -391,6 +421,27 @@ def validate_runtime_evidence(
391
421
  validate_source_refs(record, prefix, source_ids, errors)
392
422
 
393
423
 
424
+ def validate_build_evidence(data: dict[str, Any], errors: list[str]) -> None:
425
+ if "build_evidence" not in data:
426
+ return
427
+ evidence = mapping(data.get("build_evidence"), "build_evidence", errors)
428
+ artifacts = evidence.get("artifacts")
429
+ if not isinstance(artifacts, list):
430
+ errors.append("build_evidence.artifacts must be an array")
431
+ return
432
+ for index, item in enumerate(artifacts):
433
+ prefix = f"build_evidence.artifacts[{index}]"
434
+ record = mapping(item, prefix, errors)
435
+ path_value = record.get("path")
436
+ nonempty_string(path_value, f"{prefix}.path", errors)
437
+ if isinstance(path_value, str) and Path(path_value).is_absolute():
438
+ errors.append(f"{prefix}.path must be project-relative")
439
+ positive_int(record.get("size_bytes"), f"{prefix}.size_bytes", errors)
440
+ sha = record.get("sha256")
441
+ if not isinstance(sha, str) or not SHA256_RE.fullmatch(sha):
442
+ errors.append(f"{prefix}.sha256 must be a 64-character SHA-256")
443
+
444
+
394
445
  def validate_ir(data: dict[str, Any]) -> list[str]:
395
446
  errors: list[str] = []
396
447
  schema_version = data.get("schema_version")
@@ -478,11 +529,20 @@ def validate_ir(data: dict[str, Any]) -> list[str]:
478
529
  if schema_version == 2:
479
530
  validate_hardware_resources(data, source_ids, errors)
480
531
  validate_onboarding(data, source_ids, errors)
532
+ validate_build_evidence(data, errors)
481
533
  validate_runtime_evidence(data, source_ids, errors)
482
534
  return errors
483
535
 
484
536
 
485
- def codec_requirement(media: dict[str, Any], section: str) -> Requirement:
537
+ def minimum_verification_for_phase(phase: str) -> str:
538
+ if phase == "intake":
539
+ return "corroborated"
540
+ return "build_verified"
541
+
542
+
543
+ def codec_requirement(
544
+ media: dict[str, Any], section: str, minimum_verification: str = "corroborated"
545
+ ) -> Requirement:
486
546
  present = media.get("present")
487
547
  if present is None:
488
548
  return "NEEDS_CONFIRMATION", f"{section} presence is unknown", 0
@@ -496,17 +556,20 @@ def codec_requirement(media: dict[str, Any], section: str) -> Requirement:
496
556
  continue
497
557
  verification = codec.get("verification")
498
558
  level = VERIFICATION_LEVELS.get(verification, 0)
499
- if level < VERIFICATION_LEVELS["corroborated"]:
559
+ if level < VERIFICATION_LEVELS[minimum_verification]:
500
560
  return (
501
561
  "NEEDS_CONFIRMATION",
502
- f"{section} A-law 8 kHz path is only {verification}",
562
+ f"{section} A-law 8 kHz path is only {verification}; "
563
+ f"{minimum_verification} is required",
503
564
  level,
504
565
  )
505
566
  return "SATISFIED", f"{section} provides A-law 8 kHz", level
506
567
  return "BLOCKED", f"{section} has no A-law 8 kHz path", 0
507
568
 
508
569
 
509
- def legacy_video_requirement(camera: dict[str, Any]) -> Requirement:
570
+ def legacy_video_requirement(
571
+ camera: dict[str, Any], minimum_verification: str = "corroborated"
572
+ ) -> Requirement:
510
573
  present = camera.get("present")
511
574
  if present is None:
512
575
  return "NEEDS_CONFIRMATION", "camera presence is unknown", 0
@@ -530,10 +593,11 @@ def legacy_video_requirement(camera: dict[str, Any]) -> Requirement:
530
593
  return "BLOCKED", "key-frame requests cannot reach the encoder", 0
531
594
  verification = h264.get("verification")
532
595
  level = VERIFICATION_LEVELS.get(verification, 0)
533
- if level < VERIFICATION_LEVELS["corroborated"]:
596
+ if level < VERIFICATION_LEVELS[minimum_verification]:
534
597
  return (
535
598
  "NEEDS_CONFIRMATION",
536
- f"H.264 Annex-B path is only {verification}",
599
+ f"H.264 Annex-B path is only {verification}; "
600
+ f"{minimum_verification} is required",
537
601
  level,
538
602
  )
539
603
  return "SATISFIED", "camera provides H.264 Annex-B and IDR control", level
@@ -548,7 +612,9 @@ def selected_item(items: Any, selected_id: Any) -> dict[str, Any] | None:
548
612
  return None
549
613
 
550
614
 
551
- def video_requirement_v2(camera: dict[str, Any]) -> Requirement:
615
+ def video_requirement_v2(
616
+ camera: dict[str, Any], minimum_verification: str = "corroborated"
617
+ ) -> Requirement:
552
618
  present = camera.get("present")
553
619
  if present is None:
554
620
  return "NEEDS_CONFIRMATION", "camera presence is unknown", 0
@@ -585,10 +651,11 @@ def video_requirement_v2(camera: dict[str, Any]) -> Requirement:
585
651
  return "BLOCKED", "H5 refresh requests cannot reach the media pipeline", 0
586
652
  verification = profile.get("verification")
587
653
  level = VERIFICATION_LEVELS.get(verification, 0)
588
- if level < VERIFICATION_LEVELS["corroborated"]:
654
+ if level < VERIFICATION_LEVELS[minimum_verification]:
589
655
  return (
590
656
  "NEEDS_CONFIRMATION",
591
- f"selected {codec} path is only {verification}",
657
+ f"selected {codec} path is only {verification}; "
658
+ f"{minimum_verification} is required",
592
659
  level,
593
660
  )
594
661
  return (
@@ -599,7 +666,10 @@ def video_requirement_v2(camera: dict[str, Any]) -> Requirement:
599
666
 
600
667
 
601
668
  def verified_bool_requirement(
602
- section: dict[str, Any], field: str, label: str
669
+ section: dict[str, Any],
670
+ field: str,
671
+ label: str,
672
+ minimum_verification: str = "corroborated",
603
673
  ) -> Requirement:
604
674
  value = section.get(field)
605
675
  if value is None:
@@ -608,12 +678,18 @@ def verified_bool_requirement(
608
678
  return "BLOCKED", f"{label} is unresolved", 0
609
679
  verification = section.get("verification")
610
680
  level = VERIFICATION_LEVELS.get(verification, 0)
611
- if level < VERIFICATION_LEVELS["corroborated"]:
612
- return "NEEDS_CONFIRMATION", f"{label} is only {verification}", level
681
+ if level < VERIFICATION_LEVELS[minimum_verification]:
682
+ return (
683
+ "NEEDS_CONFIRMATION",
684
+ f"{label} is only {verification}; {minimum_verification} is required",
685
+ level,
686
+ )
613
687
  return "SATISFIED", f"{label} is resolved", level
614
688
 
615
689
 
616
- def i2c_requirement(resources: dict[str, Any]) -> Requirement:
690
+ def i2c_requirement(
691
+ resources: dict[str, Any], minimum_verification: str = "corroborated"
692
+ ) -> Requirement:
617
693
  i2c = resources.get("i2c", {})
618
694
  used = i2c.get("used")
619
695
  if used is None:
@@ -626,11 +702,16 @@ def i2c_requirement(resources: dict[str, Any]) -> Requirement:
626
702
  if family == "none":
627
703
  return "BLOCKED", "I2C is used but no driver family is selected", 0
628
704
  return verified_bool_requirement(
629
- i2c, "single_driver_family", f"single {family} I2C driver family"
705
+ i2c,
706
+ "single_driver_family",
707
+ f"single {family} I2C driver family plan",
708
+ minimum_verification,
630
709
  )
631
710
 
632
711
 
633
- def i2s_requirement(resources: dict[str, Any]) -> Requirement:
712
+ def i2s_requirement(
713
+ resources: dict[str, Any], minimum_verification: str = "corroborated"
714
+ ) -> Requirement:
634
715
  i2s = resources.get("i2s", {})
635
716
  used = i2s.get("used")
636
717
  if used is None:
@@ -640,11 +721,14 @@ def i2s_requirement(resources: dict[str, Any]) -> Requirement:
640
721
  return verified_bool_requirement(
641
722
  i2s,
642
723
  "controller_and_gpio_ownership_resolved",
643
- "I2S controller and GPIO ownership",
724
+ "I2S controller and GPIO ownership plan",
725
+ minimum_verification,
644
726
  )
645
727
 
646
728
 
647
- def audio_mapping_requirement(resources: dict[str, Any]) -> Requirement:
729
+ def audio_mapping_requirement(
730
+ resources: dict[str, Any], minimum_verification: str = "corroborated"
731
+ ) -> Requirement:
648
732
  channel = resources.get("audio_channel_mapping", {})
649
733
  required = channel.get("required")
650
734
  if required is None:
@@ -652,23 +736,32 @@ def audio_mapping_requirement(resources: dict[str, Any]) -> Requirement:
652
736
  if required is False:
653
737
  return "SATISFIED", "audio channel/TDM mapping is not required", 2
654
738
  return verified_bool_requirement(
655
- channel, "resolved", "audio channel/TDM mapping"
739
+ channel,
740
+ "resolved",
741
+ "audio channel/TDM mapping",
742
+ minimum_verification,
656
743
  )
657
744
 
658
745
 
659
- def camera_realtime_requirement(resources: dict[str, Any]) -> Requirement:
746
+ def camera_realtime_requirement(
747
+ resources: dict[str, Any], minimum_verification: str = "corroborated"
748
+ ) -> Requirement:
660
749
  return verified_bool_requirement(
661
750
  resources.get("camera_realtime", {}),
662
751
  "pipeline_safe",
663
- "camera DMA/task realtime policy",
752
+ "camera DMA/task realtime policy design",
753
+ minimum_verification,
664
754
  )
665
755
 
666
756
 
667
- def memory_requirement(resources: dict[str, Any]) -> Requirement:
757
+ def memory_requirement(
758
+ resources: dict[str, Any], minimum_verification: str = "corroborated"
759
+ ) -> Requirement:
668
760
  return verified_bool_requirement(
669
761
  resources.get("memory", {}),
670
762
  "startup_and_media_budgeted",
671
- "startup and media memory budget",
763
+ "static startup and media memory budget",
764
+ minimum_verification,
672
765
  )
673
766
 
674
767
 
@@ -750,7 +843,9 @@ def binding_requirement(onboarding: dict[str, Any]) -> Requirement:
750
843
 
751
844
 
752
845
  def combine_requirements(
753
- requirements: list[Requirement], legacy_hil_from_levels: bool = False
846
+ requirements: list[Requirement],
847
+ success_status: str = "READY_TO_PORT",
848
+ legacy_hil_from_levels: bool = False,
754
849
  ) -> dict[str, Any]:
755
850
  reasons = [reason for _, reason, _ in requirements]
756
851
  states = {state for state, _, _ in requirements}
@@ -766,10 +861,28 @@ def combine_requirements(
766
861
  ):
767
862
  status = "HIL_VERIFIED"
768
863
  else:
769
- status = "READY_TO_PORT"
864
+ status = success_status
770
865
  return {"status": status, "reasons": reasons}
771
866
 
772
867
 
868
+ def constrain_project_gate(
869
+ project_gate: dict[str, Any], feature_assessments: dict[str, dict[str, Any]]
870
+ ) -> None:
871
+ """Prevent a project-level pass while a requested feature is unresolved."""
872
+ feature_states = {item["status"] for item in feature_assessments.values()}
873
+ if "BLOCKED" in feature_states:
874
+ project_gate["status"] = "BLOCKED"
875
+ project_gate["reasons"].append("at least one requested feature is blocked")
876
+ elif (
877
+ "NEEDS_CONFIRMATION" in feature_states
878
+ and project_gate["status"] != "BLOCKED"
879
+ ):
880
+ project_gate["status"] = "NEEDS_CONFIRMATION"
881
+ project_gate["reasons"].append(
882
+ "at least one requested feature still needs confirmation"
883
+ )
884
+
885
+
773
886
  def project_requirements(data: dict[str, Any]) -> list[Requirement]:
774
887
  requirements: list[Requirement] = []
775
888
  revision = data["board"]["hardware_revision"].strip().lower()
@@ -839,9 +952,63 @@ def matching_runtime_evidence(
839
952
  return None
840
953
 
841
954
 
955
+ def matching_build_artifact(
956
+ data: dict[str, Any], artifact_sha256: str | None
957
+ ) -> dict[str, Any] | None:
958
+ if artifact_sha256 is None:
959
+ return None
960
+ normalized = artifact_sha256.lower()
961
+ build_evidence = data.get("build_evidence", {})
962
+ if not isinstance(build_evidence, dict):
963
+ return None
964
+ artifacts = build_evidence.get("artifacts", [])
965
+ if not isinstance(artifacts, list):
966
+ return None
967
+ for record in artifacts:
968
+ if (
969
+ isinstance(record, dict)
970
+ and str(record.get("sha256", "")).lower() == normalized
971
+ ):
972
+ return record
973
+ return None
974
+
975
+
976
+ def artifact_requirement(
977
+ data: dict[str, Any], artifact_sha256: str | None
978
+ ) -> Requirement:
979
+ if artifact_sha256 is None:
980
+ return "NEEDS_CONFIRMATION", "exact build artifact SHA-256 is missing", 0
981
+ if not SHA256_RE.fullmatch(artifact_sha256):
982
+ return "BLOCKED", "build artifact SHA-256 is invalid", 0
983
+ if matching_build_artifact(data, artifact_sha256) is None:
984
+ return (
985
+ "BLOCKED",
986
+ f"artifact {artifact_sha256} is not present in build_evidence.artifacts",
987
+ 0,
988
+ )
989
+ return (
990
+ "SATISFIED",
991
+ f"build evidence is bound to artifact {artifact_sha256}",
992
+ VERIFICATION_LEVELS["build_verified"],
993
+ )
994
+
995
+
842
996
  def assess_ir(
843
- data: dict[str, Any], artifact_sha256: str | None = None
997
+ data: dict[str, Any],
998
+ artifact_sha256: str | None = None,
999
+ phase: str | None = None,
1000
+ audio_gate: Requirement | None = None,
1001
+ video_gate: Requirement | None = None,
844
1002
  ) -> dict[str, Any]:
1003
+ selected_phase = phase or ("hil" if artifact_sha256 else "intake")
1004
+ if selected_phase not in ASSESSMENT_PHASES:
1005
+ raise ValueError(
1006
+ f"assessment phase must be one of {', '.join(sorted(ASSESSMENT_PHASES))}"
1007
+ )
1008
+ minimum_verification = minimum_verification_for_phase(selected_phase)
1009
+ success_status = (
1010
+ "READY_TO_PORT" if selected_phase == "intake" else "BUILD_VERIFIED"
1011
+ )
845
1012
  schema_version = data.get("schema_version")
846
1013
  requested = data["features"]["requested"]
847
1014
  audio_input = data["audio_input"]
@@ -858,7 +1025,7 @@ def assess_ir(
858
1025
  onboarding = data["onboarding"]
859
1026
  project.extend(
860
1027
  [
861
- i2c_requirement(resources),
1028
+ i2c_requirement(resources, minimum_verification),
862
1029
  wifi_requirement(onboarding),
863
1030
  binding_requirement(onboarding),
864
1031
  ]
@@ -875,80 +1042,130 @@ def assess_ir(
875
1042
  else:
876
1043
  resources = {}
877
1044
 
1045
+ if selected_phase in {"build", "hil"}:
1046
+ project.append(artifact_requirement(data, artifact_sha256))
1047
+
878
1048
  for feature in requested:
879
1049
  if schema_version == 1:
880
1050
  if feature == "h5_live_audio":
881
- requirements = [codec_requirement(audio_input, "audio_input")]
1051
+ requirements = [
1052
+ codec_requirement(
1053
+ audio_input, "audio_input", minimum_verification
1054
+ )
1055
+ ]
882
1056
  elif feature == "h5_live_video":
883
- requirements = [legacy_video_requirement(camera)]
1057
+ requirements = [
1058
+ legacy_video_requirement(camera, minimum_verification)
1059
+ ]
884
1060
  elif feature == "h5_talkback":
885
- requirements = [codec_requirement(audio_output, "audio_output")]
1061
+ requirements = [
1062
+ codec_requirement(
1063
+ audio_output, "audio_output", minimum_verification
1064
+ )
1065
+ ]
886
1066
  else:
887
1067
  requirements = [
888
- codec_requirement(audio_input, "audio_input"),
889
- codec_requirement(audio_output, "audio_output"),
1068
+ codec_requirement(
1069
+ audio_input, "audio_input", minimum_verification
1070
+ ),
1071
+ codec_requirement(
1072
+ audio_output, "audio_output", minimum_verification
1073
+ ),
890
1074
  ]
891
1075
  result[feature] = combine_requirements(
892
- requirements, legacy_hil_from_levels=True
1076
+ requirements,
1077
+ success_status=success_status,
1078
+ legacy_hil_from_levels=(selected_phase == "intake"),
893
1079
  )
894
1080
  continue
895
1081
 
896
1082
  if feature == "h5_live_audio":
897
1083
  requirements = [
898
- codec_requirement(audio_input, "audio_input"),
899
- i2s_requirement(resources),
900
- audio_mapping_requirement(resources),
901
- memory_requirement(resources),
1084
+ codec_requirement(audio_input, "audio_input", minimum_verification),
1085
+ i2s_requirement(resources, minimum_verification),
1086
+ audio_mapping_requirement(resources, minimum_verification),
1087
+ memory_requirement(resources, minimum_verification),
902
1088
  ]
903
1089
  elif feature == "h5_live_video":
904
1090
  requirements = [
905
- video_requirement_v2(camera),
906
- camera_realtime_requirement(resources),
907
- memory_requirement(resources),
1091
+ video_requirement_v2(camera, minimum_verification),
1092
+ camera_realtime_requirement(resources, minimum_verification),
1093
+ memory_requirement(resources, minimum_verification),
908
1094
  ]
909
1095
  elif feature == "h5_talkback":
910
1096
  requirements = [
911
- codec_requirement(audio_output, "audio_output"),
912
- i2s_requirement(resources),
913
- memory_requirement(resources),
1097
+ codec_requirement(audio_output, "audio_output", minimum_verification),
1098
+ i2s_requirement(resources, minimum_verification),
1099
+ memory_requirement(resources, minimum_verification),
914
1100
  ]
915
1101
  else:
916
1102
  requirements = [
917
- codec_requirement(audio_input, "audio_input"),
918
- codec_requirement(audio_output, "audio_output"),
919
- i2s_requirement(resources),
920
- audio_mapping_requirement(resources),
921
- memory_requirement(resources),
1103
+ codec_requirement(audio_input, "audio_input", minimum_verification),
1104
+ codec_requirement(audio_output, "audio_output", minimum_verification),
1105
+ i2s_requirement(resources, minimum_verification),
1106
+ audio_mapping_requirement(resources, minimum_verification),
1107
+ memory_requirement(resources, minimum_verification),
922
1108
  ]
923
- result[feature] = combine_requirements(requirements)
1109
+ if feature in AUDIO_FEATURES and selected_phase in {"build", "hil"}:
1110
+ requirements.append(
1111
+ audio_gate
1112
+ or (
1113
+ "NEEDS_CONFIRMATION",
1114
+ "audio semantic gate was not executed for this project",
1115
+ 0,
1116
+ )
1117
+ )
1118
+ if feature in VIDEO_FEATURES and selected_phase in {"build", "hil"}:
1119
+ requirements.append(
1120
+ video_gate
1121
+ or (
1122
+ "NEEDS_CONFIRMATION",
1123
+ "video semantic gate was not executed for this project",
1124
+ 0,
1125
+ )
1126
+ )
1127
+ result[feature] = combine_requirements(
1128
+ requirements, success_status=success_status
1129
+ )
924
1130
 
925
1131
  project_gate = combine_requirements(
926
- project, legacy_hil_from_levels=(schema_version == 1)
1132
+ project,
1133
+ success_status=success_status,
1134
+ legacy_hil_from_levels=(schema_version == 1 and selected_phase == "intake"),
927
1135
  )
1136
+ build_artifact = matching_build_artifact(data, artifact_sha256)
928
1137
  evidence = matching_runtime_evidence(data, artifact_sha256)
929
- if schema_version == 2 and evidence is not None:
930
- evidence_features = set(evidence.get("features", []))
931
- evidence_levels = set(evidence.get("acceptance_levels", []))
932
- for feature, assessment in result.items():
933
- if (
934
- assessment["status"] == "READY_TO_PORT"
935
- and feature in evidence_features
936
- and FEATURE_HIL_LEVEL[feature] in evidence_levels
937
- ):
938
- assessment["status"] = "HIL_VERIFIED"
939
- assessment["reasons"].append(
940
- f"artifact {artifact_sha256} passed {FEATURE_HIL_LEVEL[feature]}"
1138
+ if schema_version == 2 and selected_phase == "hil":
1139
+ evidence_features = set(evidence.get("features", [])) if evidence else set()
1140
+ evidence_levels = (
1141
+ set(evidence.get("acceptance_levels", [])) if evidence else set()
1142
+ )
1143
+ for feature, feature_assessment in result.items():
1144
+ if feature_assessment["status"] != "BUILD_VERIFIED":
1145
+ continue
1146
+ required_level = FEATURE_HIL_LEVEL[feature]
1147
+ if feature in evidence_features and required_level in evidence_levels:
1148
+ feature_assessment["status"] = "HIL_VERIFIED"
1149
+ feature_assessment["reasons"].append(
1150
+ f"artifact {artifact_sha256} passed {required_level}"
1151
+ )
1152
+ else:
1153
+ feature_assessment["reasons"].append(
1154
+ f"artifact {artifact_sha256} has no matching {required_level} runtime evidence"
941
1155
  )
942
1156
  if result and all(
943
1157
  item["status"] == "HIL_VERIFIED" for item in result.values()
944
- ):
1158
+ ) and project_gate["status"] == "BUILD_VERIFIED":
945
1159
  project_gate["status"] = "HIL_VERIFIED"
946
1160
  project_gate["reasons"].append(
947
1161
  f"all requested features have matching artifact evidence for {artifact_sha256}"
948
1162
  )
949
1163
 
1164
+ constrain_project_gate(project_gate, result)
1165
+
950
1166
  assessment: dict[str, Any] = {
951
1167
  "schema_version": schema_version,
1168
+ "phase": selected_phase,
952
1169
  "board_id": data["board"]["id"],
953
1170
  "hardware_revision": data["board"]["hardware_revision"],
954
1171
  "project_gate": project_gate,
@@ -959,7 +1176,8 @@ def assess_ir(
959
1176
  assessment["selected_wifi_method"] = selected_wifi
960
1177
  assessment["selected_binding_method"] = selected_binding
961
1178
  assessment["artifact_sha256"] = artifact_sha256
962
- assessment["artifact_evidence_matched"] = evidence is not None
1179
+ assessment["build_artifact_evidence_matched"] = build_artifact is not None
1180
+ assessment["runtime_artifact_evidence_matched"] = evidence is not None
963
1181
  return assessment
964
1182
 
965
1183
 
@@ -1005,12 +1223,122 @@ def command_assess(args: argparse.Namespace) -> int:
1005
1223
  for error in errors:
1006
1224
  print(f"error: {error}", file=sys.stderr)
1007
1225
  return 2
1008
- assessment = assess_ir(data, artifact_sha256=args.artifact_sha256)
1226
+ if args.artifact_sha256 is not None and not SHA256_RE.fullmatch(
1227
+ args.artifact_sha256
1228
+ ):
1229
+ print("error: --artifact-sha256 must be a 64-character SHA-256", file=sys.stderr)
1230
+ return 2
1231
+ selected_phase = args.phase or (
1232
+ "hil" if args.artifact_sha256 is not None else "intake"
1233
+ )
1234
+ project = (args.project or args.path.parent).expanduser().resolve()
1235
+ audio_gate: Requirement | None = None
1236
+ audio_gate_result: dict[str, Any] | None = None
1237
+ if (
1238
+ data.get("schema_version") == 2
1239
+ and selected_phase in {"build", "hil"}
1240
+ and AUDIO_FEATURES.intersection(data["features"]["requested"])
1241
+ ):
1242
+ relative_contract = data["hardware_resources"].get(
1243
+ "audio_semantic_contract"
1244
+ )
1245
+ if not isinstance(relative_contract, str) or not relative_contract:
1246
+ audio_gate = (
1247
+ "NEEDS_CONFIRMATION",
1248
+ "hardware_resources.audio_semantic_contract is missing",
1249
+ 0,
1250
+ )
1251
+ audio_gate_result = {
1252
+ "ok": False,
1253
+ "errors": ["audio semantic contract is missing"],
1254
+ }
1255
+ else:
1256
+ contract = (project / relative_contract).resolve()
1257
+ if project != contract and project not in contract.parents:
1258
+ audio_gate_result = {
1259
+ "ok": False,
1260
+ "errors": ["audio semantic contract escapes project root"],
1261
+ }
1262
+ else:
1263
+ audio_gate_result = verify_audio_contract(contract, project)
1264
+ if audio_gate_result["ok"]:
1265
+ audio_gate = (
1266
+ "SATISFIED",
1267
+ "audio semantic gate passed: "
1268
+ + str(audio_gate_result.get("summary", "verified")),
1269
+ VERIFICATION_LEVELS["build_verified"],
1270
+ )
1271
+ else:
1272
+ audio_gate = (
1273
+ "BLOCKED",
1274
+ "audio semantic gate failed: "
1275
+ + "; ".join(audio_gate_result.get("errors", [])),
1276
+ 0,
1277
+ )
1278
+ video_gate: Requirement | None = None
1279
+ video_gate_result: dict[str, Any] | None = None
1280
+ if (
1281
+ data.get("schema_version") == 2
1282
+ and selected_phase in {"build", "hil"}
1283
+ and VIDEO_FEATURES.intersection(data["features"]["requested"])
1284
+ ):
1285
+ relative_contract = data["hardware_resources"].get(
1286
+ "video_semantic_contract"
1287
+ )
1288
+ if not isinstance(relative_contract, str) or not relative_contract:
1289
+ video_gate = (
1290
+ "NEEDS_CONFIRMATION",
1291
+ "hardware_resources.video_semantic_contract is missing",
1292
+ 0,
1293
+ )
1294
+ video_gate_result = {
1295
+ "ok": False,
1296
+ "errors": ["video semantic contract is missing"],
1297
+ }
1298
+ else:
1299
+ contract = (project / relative_contract).resolve()
1300
+ if project != contract and project not in contract.parents:
1301
+ video_gate_result = {
1302
+ "ok": False,
1303
+ "errors": ["video semantic contract escapes project root"],
1304
+ }
1305
+ else:
1306
+ video_gate_result = verify_video_contract(contract, project)
1307
+ if video_gate_result["ok"]:
1308
+ video_gate = (
1309
+ "SATISFIED",
1310
+ "video semantic gate passed: "
1311
+ + str(video_gate_result.get("summary", "verified")),
1312
+ VERIFICATION_LEVELS["build_verified"],
1313
+ )
1314
+ else:
1315
+ video_gate = (
1316
+ "BLOCKED",
1317
+ "video semantic gate failed: "
1318
+ + "; ".join(video_gate_result.get("errors", [])),
1319
+ 0,
1320
+ )
1321
+ try:
1322
+ assessment = assess_ir(
1323
+ data,
1324
+ artifact_sha256=args.artifact_sha256,
1325
+ phase=selected_phase,
1326
+ audio_gate=audio_gate,
1327
+ video_gate=video_gate,
1328
+ )
1329
+ except ValueError as exc:
1330
+ print(f"error: {exc}", file=sys.stderr)
1331
+ return 2
1332
+ if audio_gate_result is not None:
1333
+ assessment["audio_semantic_gate"] = audio_gate_result
1334
+ if video_gate_result is not None:
1335
+ assessment["video_semantic_gate"] = video_gate_result
1009
1336
  print(json.dumps(assessment, ensure_ascii=False, indent=2))
1010
1337
  if args.strict:
1011
1338
  statuses = {item["status"] for item in assessment["features"].values()}
1012
1339
  statuses.add(assessment["project_gate"]["status"])
1013
- if not statuses.issubset(READY_STATUSES):
1340
+ allowed_statuses = PHASE_SUCCESS_STATUSES[assessment["phase"]]
1341
+ if not statuses.issubset(allowed_statuses):
1014
1342
  return 3
1015
1343
  return 0
1016
1344
 
@@ -1036,11 +1364,21 @@ def parse_args() -> argparse.Namespace:
1036
1364
  assess_parser.add_argument(
1037
1365
  "--strict",
1038
1366
  action="store_true",
1039
- help="return non-zero unless every requested feature is ready or HIL verified",
1367
+ help="return non-zero unless every requested feature passes the selected phase",
1368
+ )
1369
+ assess_parser.add_argument(
1370
+ "--phase",
1371
+ choices=sorted(ASSESSMENT_PHASES),
1372
+ help="assessment phase; defaults to intake, or hil when an artifact SHA is supplied",
1040
1373
  )
1041
1374
  assess_parser.add_argument(
1042
1375
  "--artifact-sha256",
1043
- help="bind HIL status to runtime evidence for this exact firmware artifact",
1376
+ help="bind build or HIL assessment to this exact firmware artifact",
1377
+ )
1378
+ assess_parser.add_argument(
1379
+ "--project",
1380
+ type=Path,
1381
+ help="generated project root; defaults to the Hardware IR directory",
1044
1382
  )
1045
1383
  assess_parser.set_defaults(handler=command_assess)
1046
1384
  return parser.parse_args()